diff --git a/docs/architecture/chain-map-artifact-lifecycle.md b/docs/architecture/chain-map-artifact-lifecycle.md index 59e6df4a..0a9ca38c 100644 --- a/docs/architecture/chain-map-artifact-lifecycle.md +++ b/docs/architecture/chain-map-artifact-lifecycle.md @@ -16,6 +16,12 @@ Repo chain: openclaw-multi-session-plugins ↔ xworkmate-bridge ↔ xworkmate-ap sync: save to ~/.xworkmate/threads// (xworkmate-app) ``` +App terminal rule: +- `completed`, `failed`, `cancelled`, and `canceled` snapshots end task + execution immediately. +- Artifact presence controls only `lastArtifactSyncStatus`; it is not a reason + to keep `lifecycleStatus=running`. + ## State 1: Prepare ``` @@ -47,7 +53,7 @@ Output: artifactDirectory: "/tasks///" Fragile: - - workspace resolution chain has 5 fallback levels + - workspace resolution chain has 5 ordered sources - session key format must match across bridge and plugin - no cleanup of old scope directories ``` @@ -212,6 +218,9 @@ Fragile: - If export returns empty manifest, snapshot has no artifacts - Artifact download URLs expire after 24h - Snapshot stored only in memory (lost on bridge restart) + - App execution state must still transition to ready for any terminal + snapshot. Empty or incomplete artifact manifests update only artifact sync + status; they must not keep the task lifecycle running. ``` ## State 6: Download (Bridge Proxy) diff --git a/docs/architecture/chain-map-session-recovery.md b/docs/architecture/chain-map-session-recovery.md index f9beb2b9..e7edc2a8 100644 --- a/docs/architecture/chain-map-session-recovery.md +++ b/docs/architecture/chain-map-session-recovery.md @@ -15,7 +15,7 @@ App starts ├─ thread has pendingTurnId? │ ├─ Yes → pollBridgeTaskSnapshot(turnId) │ │ └─ xworkmate.tasks.get({ sessionId, threadId, turnId }) - │ │ ├─ Terminal snapshot found → apply result + │ │ ├─ Terminal snapshot found → apply result and mark ready │ │ ├─ Session not found → mark failed │ │ └─ No response → mark unrecovered │ └─ No → mark ready (no pending turn) @@ -56,7 +56,7 @@ SSE stream interrupted (network flap) └─ App: Transport detects stream close without terminal └─ Enter polling mode └─ Every N seconds: xworkmate.tasks.get({ sessionId, threadId, turnId }) - ├─ Terminal snapshot → apply result, stop polling + ├─ Terminal snapshot → apply result, mark ready, stop polling ├─ Still running → continue polling ├─ Session not found → mark failed └─ Max poll attempts reached → mark unrecovered @@ -128,7 +128,7 @@ stateDiagram-v2 Polling --> Session_Not_Found: bridge restarted Polling --> Max_Retries: exceeded - Recovered --> Ready: result applied + Recovered --> Ready: result applied; artifact sync status records missing outputs Session_Not_Found --> Failed: ACP_BRIDGE_RESTART Max_Retries --> Failed: ACP_UNRECOVERABLE @@ -180,7 +180,7 @@ resolveGatewayThreadConnectionState(thread) │ ├─ thread.lastTurnId exists? │ │ ├─ Yes → transport.pollBridgeTaskSnapshot(turnId) │ │ │ └─ xworkmate.tasks.get: - │ │ │ ├─ completed/failed → applyGatewayChatResult() + │ │ │ ├─ completed/failed → applyGatewayChatResult() and mark ready │ │ │ ├─ running → leave as running, continue SSE │ │ │ └─ not found / error: │ │ │ ├─ isBridgeAvailable() diff --git a/docs/architecture/chain-map-task-execution.md b/docs/architecture/chain-map-task-execution.md index 81006289..6a53673d 100644 --- a/docs/architecture/chain-map-task-execution.md +++ b/docs/architecture/chain-map-task-execution.md @@ -62,7 +62,7 @@ xworkmate-bridge ├─ Routing engine: Resolve(params, prompt, memory) │ ├─ Heuristic: looksLocal() / looksOnline() │ ├─ Memory preferences - │ └─ LLM classifier fallback + │ └─ LLM classifier path │ ├─ openClawGatewayAdmissionGate.acquire() │ ├─ maxActive: 5, maxQueued: 20 @@ -169,6 +169,7 @@ now copied into tasks///artifacts/ before export. ExternalCodeAgentAcpDesktopTransport ├─ Receive terminal snapshot via SSE or xworkmate.tasks.get ├─ applyGatewayChatResult() + │ ├─ Terminal snapshot → lifecycleStatus=ready │ ├─ success=true && artifacts present → syncArtifactsFromBridge() │ ├─ success=false → lastResultCode=failed │ └─ no-exported-artifacts → lastArtifactSyncStatus=no-exported-artifacts @@ -176,6 +177,11 @@ now copied into tasks///artifacts/ before export. └─ syncArtifactsFromBridge() └─ Download each artifact via /artifacts/openclaw/download → Save to ~/.xworkmate/threads// + +Rule: the app must not keep an OpenClaw task in `running` after a bridge +terminal snapshot. Missing or incomplete artifacts are represented only through +`lastArtifactSyncStatus` (`no-exported-artifacts`, `partial`, `download-failed`, +etc.), not by extending the task execution lifecycle. ``` ## Key Files by Repo @@ -217,4 +223,4 @@ now copied into tasks///artifacts/ before export. 4. **F4: Admission gate rejection** — Queue full → OPENCLAW_GATEWAY_BUSY → app must handle 5. **F5: Bridge restart** — In-memory sessions lost → app must detect and recover 6. **F6: Artifact ref key rotation** — Secret change invalidates all signed refs -7. **F7: SSE stream interruption** — Polling fallback must have correct timeout/retry +7. **F7: SSE stream interruption** — Recovery polling must align with bridge task deadlines and must apply terminal snapshots immediately diff --git a/lib/app/app_controller_desktop_runtime_helpers.dart b/lib/app/app_controller_desktop_runtime_helpers.dart index 30155446..dca9acdc 100644 --- a/lib/app/app_controller_desktop_runtime_helpers.dart +++ b/lib/app/app_controller_desktop_runtime_helpers.dart @@ -70,13 +70,9 @@ extension AppControllerDesktopRuntimeHelpers on AppController { appUiState.assistantLastSessionKey == normalizedSessionKey) { return; } - try { - await saveAppUiStateInternal( - appUiState.copyWith(assistantLastSessionKey: normalizedSessionKey), - ); - } catch (_) { - // Best effort only during teardown-sensitive transitions. - } + await saveAppUiStateInternal( + appUiState.copyWith(assistantLastSessionKey: normalizedSessionKey), + ); } void setAiGatewayStreamingTextInternal(String sessionKey, String text) { @@ -381,25 +377,22 @@ extension AppControllerDesktopRuntimeHelpers on AppController { return const []; } final root = Directory(thread.workspaceBinding.workspacePath); - try { - final policy = await _loadArtifactSyncPolicyInternal( - root, - thread.selectedSkillKeys, - ); - return _workspaceArtifactPathsModifiedSinceInternal( - root, - thread.lifecycleState.lastRunAtMs, - policy, - ); - } catch (_) { - return const []; - } + final policy = await _loadArtifactSyncPolicyInternal( + root, + thread.selectedSkillKeys, + ); + return _workspaceArtifactPathsModifiedSinceInternal( + root, + thread.lifecycleState.lastRunAtMs, + policy, + ); } String jsonLikeTextForDiagnosticsInternal(Object? value) { try { return jsonEncode(value); - } catch (_) { + } catch (error) { + debugPrint('JSON diagnostic encoding failed: $error'); return value.toString(); } } @@ -781,6 +774,9 @@ extension AppControllerDesktopRuntimeHelpers on AppController { ); final artifacts = result.artifacts; if (artifacts.isEmpty) { + final requiredExts = + existingThread.openClawTaskAssociation?.requiredArtifactExtensions ?? + const []; final currentTaskArtifactRelativePaths = isOpenClawNoExportedArtifactsGuardResultInternal(result) ? const [] @@ -803,7 +799,8 @@ extension AppControllerDesktopRuntimeHelpers on AppController { normalizedSessionKey, lastArtifactSyncAtMs: syncedAtMs, lastArtifactSyncStatus: - isOpenClawNoExportedArtifactsGuardResultInternal(result) + isOpenClawNoExportedArtifactsGuardResultInternal(result) || + requiredExts.isNotEmpty ? 'no-exported-artifacts' : 'no-artifacts', updatedAtMs: syncedAtMs, @@ -874,18 +871,21 @@ extension AppControllerDesktopRuntimeHelpers on AppController { } final thread = taskThreadForSessionInternal(normalizedSessionKey); - final requiredExts = thread?.openClawTaskAssociation - ?.requiredArtifactExtensions ?? const []; - final missingRequired = requiredExts.where((ext) { - return !currentTaskArtifactPaths.any( - (p) => p.toLowerCase().endsWith(ext.toLowerCase()), - ); - }).toList(growable: false); + final requiredExts = + thread?.openClawTaskAssociation?.requiredArtifactExtensions ?? + const []; + final missingRequired = requiredExts + .where((ext) { + return !currentTaskArtifactPaths.any( + (p) => p.toLowerCase().endsWith(ext.toLowerCase()), + ); + }) + .toList(growable: false); final syncStatus = wroteArtifact ? (failedArtifact || skippedArtifact || missingRequired.isNotEmpty - ? 'partial' - : 'synced') + ? 'partial' + : 'synced') : failedArtifact ? 'download-failed' : rejectedArtifact @@ -928,11 +928,12 @@ extension AppControllerDesktopRuntimeHelpers on AppController { final bridgeEndpoint = resolveBridgeAcpEndpointInternal(); final bridgeHost = bridgeEndpoint?.host.trim().toLowerCase() ?? ''; final downloadHost = uri.host.trim().toLowerCase(); - final isLoopback = downloadHost == '127.0.0.1' || + final isLoopback = + downloadHost == '127.0.0.1' || downloadHost == 'localhost' || downloadHost == '::1'; - final sameBridgeHost = bridgeEndpoint != null && - (downloadHost == bridgeHost || isLoopback); + final sameBridgeHost = + bridgeEndpoint != null && (downloadHost == bridgeHost || isLoopback); if (!sameBridgeHost) { return const _ArtifactBytesResult.skipped(); } @@ -1066,7 +1067,8 @@ extension AppControllerDesktopRuntimeHelpers on AppController { } await temp.rename(target.path); return true; - } catch (_) { + } catch (error) { + debugPrint('Artifact write failed for ${target.path}: $error'); if (await temp.exists()) { await temp.delete(); } diff --git a/lib/app/app_controller_desktop_thread_actions.dart b/lib/app/app_controller_desktop_thread_actions.dart index e8ba3269..8570972e 100644 --- a/lib/app/app_controller_desktop_thread_actions.dart +++ b/lib/app/app_controller_desktop_thread_actions.dart @@ -359,15 +359,10 @@ extension AppControllerDesktopThreadActions on AppController { bridgeCapabilityRefreshNeededForAssistantTargetInternal( currentTarget, )) { - try { - await refreshAcpCapabilitiesInternal(forceRefresh: true); - connectionState = assistantConnectionStateForSession( - normalizedSessionKey, - ); - } catch (error) { - debugPrint('Gateway capability refresh fallback: $error'); - // Fallback to existing connection state if refresh fails. - } + await refreshAcpCapabilitiesInternal(forceRefresh: true); + connectionState = assistantConnectionStateForSession( + normalizedSessionKey, + ); } if (!connectionState.connected) { final error = StateError(connectionState.detailLabel); @@ -410,12 +405,7 @@ extension AppControllerDesktopThreadActions on AppController { throw error; } if (providerCatalogForExecutionTarget(currentTarget).isEmpty) { - try { - await refreshSingleAgentCapabilitiesInternal(forceRefresh: true); - } catch (error) { - debugPrint('Gateway provider catalog refresh fallback: $error'); - // Keep the local guard focused on the post-refresh catalog state. - } + await refreshSingleAgentCapabilitiesInternal(forceRefresh: true); if (providerCatalogForExecutionTarget(currentTarget).isEmpty) { upsertTaskThreadInternal( normalizedSessionKey, @@ -784,16 +774,6 @@ extension AppControllerDesktopThreadActions on AppController { continue; } if (aiGatewayPendingSessionKeysInternal.contains(sessionKey)) { - final hasRequiredExts = current.requiredArtifactExtensions.isNotEmpty; - final hasEnoughArtifacts = !hasRequiredExts || - current.requiredArtifactExtensions.every((ext) { - return result.artifacts.any( - (a) => a.relativePath.toLowerCase().endsWith(ext.toLowerCase()), - ); - }); - if (!hasEnoughArtifacts && attempt < maxAttempts - 1) { - continue; - } await applyGatewayChatResultInternal( sessionKey: sessionKey, target: target, @@ -1296,8 +1276,6 @@ extension AppControllerDesktopThreadActions on AppController { notifyIfActiveInternal(); } - - Future applyGatewayChatResultInternal({ required String sessionKey, required AssistantExecutionTarget target, @@ -1327,11 +1305,15 @@ extension AppControllerDesktopThreadActions on AppController { lifecycleStatus: 'ready', lastRunAtMs: completedAtMs, lastResultCode: terminalResultCode, - clearOpenClawTaskAssociation: true, updatedAtMs: completedAtMs, ); if (isOpenClawNoExportedArtifactsGuardResultInternal(result)) { await persistGoTaskArtifactsForSessionInternal(sessionKey, result); + upsertTaskThreadInternal( + sessionKey, + clearOpenClawTaskAssociation: true, + updatedAtMs: completedAtMs, + ); return; } if (!result.success) { @@ -1340,6 +1322,7 @@ extension AppControllerDesktopThreadActions on AppController { lastArtifactSyncAtMs: completedAtMs, lastArtifactSyncStatus: 'failed', lastTaskArtifactRelativePaths: const [], + clearOpenClawTaskAssociation: true, updatedAtMs: completedAtMs, ); appendLocalSessionMessageInternal( @@ -1365,6 +1348,7 @@ extension AppControllerDesktopThreadActions on AppController { lastArtifactSyncAtMs: completedAtMs, lastArtifactSyncStatus: 'failed', lastTaskArtifactRelativePaths: const [], + clearOpenClawTaskAssociation: true, updatedAtMs: completedAtMs, ); appendLocalSessionMessageInternal( @@ -1404,6 +1388,7 @@ extension AppControllerDesktopThreadActions on AppController { lifecycleStatus: 'ready', lastRunAtMs: completedAtMs, lastResultCode: terminalResultCode, + clearOpenClawTaskAssociation: true, updatedAtMs: completedAtMs, ); } @@ -1546,23 +1531,28 @@ extension AppControllerDesktopThreadActions on AppController { bool gatewayResultCodeRequiresNewSessionInternal(String code) { final normalized = code.trim().toUpperCase(); - if (normalized.isEmpty || - normalized == 'ACP_HTTP_CONNECTION_CLOSED') { + if (normalized.isEmpty || normalized == 'ACP_HTTP_CONNECTION_CLOSED') { return false; } - if (normalized == 'RUNNING' || - normalized == 'QUEUED' || - normalized == 'ABORTED' || - normalized == 'BRIDGE_NOT_CONNECTED' || - normalized == 'ARTIFACT_MISSING') { - return true; - } - if (normalized.startsWith('OPENCLAW_') || - normalized.startsWith('ACP_HTTP_') || - normalized.startsWith('GATEWAY_')) { - return true; // Conservative fallback for unrecognized infrastructure/gateway errors - } - return false; + return const { + 'RUNNING', + 'QUEUED', + 'ABORTED', + 'BRIDGE_NOT_CONNECTED', + 'ARTIFACT_MISSING', + 'OPENCLAW_ARTIFACT_MISSING', + 'OPENCLAW_GATEWAY_QUEUE_FULL', + 'OPENCLAW_GATEWAY_SOCKET_CLOSED', + 'OPENCLAW_NO_DISPLAYABLE_OUTPUT', + 'OPENCLAW_NO_EXPORTED_ARTIFACTS', + 'OPENCLAW_WAIT_FAILED', + 'ACP_HTTP_401', + 'ACP_HTTP_502', + 'ACP_HTTP_CONNECT_FAILED', + 'ACP_HTTP_CONNECT_TIMEOUT', + 'ACP_HTTP_HANDSHAKE_INTERRUPTED', + 'GATEWAY_TASK_REJECTED', + }.contains(normalized); } Future abortRun() async { @@ -1587,27 +1577,17 @@ extension AppControllerDesktopThreadActions on AppController { final association = taskThreadForSessionInternal( normalized, )?.openClawTaskAssociation; - try { - await goTaskServiceClientInternal.cancelTask( - route: GoTaskServiceRoute.externalAcpSingle, - target: assistantExecutionTargetForSession(normalized), - sessionId: normalized, - threadId: normalized, - association: association, - ); - } catch (error) { - debugPrint('OpenClaw cancellation fallback: $error'); - // Best effort cancellation only. Local state must still leave pending. - } + await goTaskServiceClientInternal.cancelTask( + route: GoTaskServiceRoute.externalAcpSingle, + target: assistantExecutionTargetForSession(normalized), + sessionId: normalized, + threadId: normalized, + association: association, + ); } Future prepareForExit() async { - try { - await abortRun(); - } catch (error) { - debugPrint('Prepare for exit abort fallback: $error'); - // Best effort only. Native termination still proceeds. - } + await abortRun(); await flushAssistantThreadPersistenceInternal(); } diff --git a/lib/runtime/external_code_agent_acp_desktop_transport.dart b/lib/runtime/external_code_agent_acp_desktop_transport.dart index c3790d2f..a23d4be6 100644 --- a/lib/runtime/external_code_agent_acp_desktop_transport.dart +++ b/lib/runtime/external_code_agent_acp_desktop_transport.dart @@ -6,8 +6,7 @@ import 'gateway_acp_client.dart'; import 'go_task_service_client.dart'; import 'runtime_models.dart'; -class ExternalCodeAgentAcpDesktopTransport - implements GoTaskServiceClient { +class ExternalCodeAgentAcpDesktopTransport implements GoTaskServiceClient { ExternalCodeAgentAcpDesktopTransport({ required GatewayAcpClient client, required Uri? Function(AssistantExecutionTarget target) endpointResolver, @@ -26,8 +25,6 @@ class ExternalCodeAgentAcpDesktopTransport final Duration _recoveryPollDelay; final int? _recoveryMaxAttempts; - - @override Future executeTask( GoTaskServiceRequest request, { @@ -35,7 +32,6 @@ class ExternalCodeAgentAcpDesktopTransport }) async { var streamedText = ''; String? completedMessage; - Map? completedResultSnapshot; Map? runningTaskSnapshot; try { final endpointOverride = _taskEndpointResolver == null @@ -65,9 +61,6 @@ class ExternalCodeAgentAcpDesktopTransport } if (update.isDone && update.message.trim().isNotEmpty) { completedMessage = update.message.trim(); - completedResultSnapshot = _completedResultSnapshotFromUpdate( - update, - ); } if (update.payload['status']?.toString().trim().toLowerCase() == 'running' && @@ -92,24 +85,11 @@ class ExternalCodeAgentAcpDesktopTransport : _taskEndpointResolver.call(request), streamedText: streamedText, completedMessage: completedMessage, - fallbackAvailable: completedResultSnapshot != null, runningTaskSnapshot: runningTaskSnapshot, ); if (recovered != null) { return recovered; } - if (completedResultSnapshot != null) { - return goTaskServiceResultFromAcpResponse( - { - 'jsonrpc': '2.0', - 'id': 'recovered-from-completed-session-update', - 'result': completedResultSnapshot, - }, - route: request.route, - streamedText: streamedText, - completedMessage: completedMessage, - ); - } } rethrow; } on SocketException catch (error) { @@ -141,7 +121,6 @@ class ExternalCodeAgentAcpDesktopTransport required Uri? taskEndpoint, required String streamedText, required String? completedMessage, - bool fallbackAvailable = false, Map? runningTaskSnapshot, }) async { final endpoint = _sessionSnapshotEndpoint(taskEndpoint); @@ -160,7 +139,8 @@ class ExternalCodeAgentAcpDesktopTransport try { response = await _client.request( method: 'xworkmate.tasks.get', - params: association?.toTaskGetParams() ?? + params: + association?.toTaskGetParams() ?? { 'sessionId': request.sessionId, 'threadId': request.threadId, @@ -168,9 +148,6 @@ class ExternalCodeAgentAcpDesktopTransport endpointOverride: endpoint, ); } on GatewayAcpException { - if (fallbackAvailable) { - return null; - } continue; } on SocketException { continue; @@ -198,14 +175,6 @@ class ExternalCodeAgentAcpDesktopTransport ); } final result = _recoveredResultFromTaskSnapshot(snapshot); - final resultArtifacts = _castMap(result['artifacts']); - final artifactItems = resultArtifacts['items'] ?? resultArtifacts; - final hasArtifacts = result.isNotEmpty && - (artifactItems is List && artifactItems.isNotEmpty || - result['artifacts'] is List && (result['artifacts'] as List).isNotEmpty); - if (!hasArtifacts && status == 'completed' && attempt < attempts - 1) { - continue; - } if (result.isNotEmpty) { return goTaskServiceResultFromAcpResponse( { @@ -287,43 +256,8 @@ class ExternalCodeAgentAcpDesktopTransport ); } - - @override Future dispose() => _client.dispose(); - - Map? _completedResultSnapshotFromUpdate( - GoTaskServiceUpdate update, - ) { - if (!update.isDone) { - return null; - } - final payload = update.payload; - final embeddedResult = _castMap(payload['result']); - final snapshot = {...embeddedResult, ...payload}; - snapshot.remove('sessionId'); - snapshot.remove('threadId'); - snapshot.remove('type'); - snapshot.remove('event'); - snapshot.remove('pending'); - snapshot.remove('result'); - snapshot['turnId'] = update.turnId; - snapshot['success'] = !update.error; - final text = _firstNonEmptyDisplayText(snapshot, const [ - 'output', - 'message', - 'summary', - 'text', - 'delta', - ]); - if (text.isNotEmpty) { - snapshot['output'] = text; - snapshot['message'] = text; - snapshot['summary'] = text; - } - return snapshot; - } - Map _recoveredResultFromTaskSnapshot( Map snapshot, ) { diff --git a/lib/runtime/go_task_service_client.dart b/lib/runtime/go_task_service_client.dart index 4f23b96c..652d7379 100644 --- a/lib/runtime/go_task_service_client.dart +++ b/lib/runtime/go_task_service_client.dart @@ -496,28 +496,22 @@ class GoTaskServiceResult { ? raw['resultSummary'].toString().trim() : raw['summary']?.toString().trim() ?? ''; - String get status => _firstNestedGoTaskString( - raw, - const >[ - ['status'], - ['error', 'status'], - ['details', 'status'], - ['payload', 'status'], - ['result', 'status'], - ], - ); + String get status => _firstNestedGoTaskString(raw, const >[ + ['status'], + ['error', 'status'], + ['details', 'status'], + ['payload', 'status'], + ['result', 'status'], + ]); - String get code => _firstNestedGoTaskString( - raw, - const >[ - ['code'], - ['error', 'code'], - ['error', 'details', 'code'], - ['details', 'code'], - ['payload', 'code'], - ['result', 'code'], - ], - ); + String get code => _firstNestedGoTaskString(raw, const >[ + ['code'], + ['error', 'code'], + ['error', 'details', 'code'], + ['details', 'code'], + ['payload', 'code'], + ['result', 'code'], + ]); bool get isOpenClawRunningTaskHandle { final normalizedStatus = status.trim().toLowerCase(); @@ -755,9 +749,8 @@ GoTaskServiceResult goTaskServiceResultFromAcpResponse( .map((item) => item['id']?.toString().trim() ?? '') .where((item) => item.isNotEmpty) .toList(growable: false); - final success = - _boolValue(result['success']) ?? _inferGoTaskSuccess(result); - final fallbackFailureText = () { + final success = _boolValue(result['success']) ?? _inferGoTaskSuccess(result); + final structuredFailureText = () { if (success) { return ''; } @@ -778,8 +771,8 @@ GoTaskServiceResult goTaskServiceResultFromAcpResponse( ? responseText : completedMessage?.trim().isNotEmpty == true ? completedMessage!.trim() - : fallbackFailureText.isNotEmpty - ? fallbackFailureText + : structuredFailureText.isNotEmpty + ? structuredFailureText : streamedText.trim().isNotEmpty ? streamedText.trim() : '') @@ -787,8 +780,8 @@ GoTaskServiceResult goTaskServiceResultFromAcpResponse( final directErrorMessage = _extractGoTaskDisplayText(result['error']); final effectiveErrorMessage = success ? directErrorMessage - : fallbackFailureText.isNotEmpty - ? fallbackFailureText + : structuredFailureText.isNotEmpty + ? structuredFailureText : primaryText.isNotEmpty ? primaryText : directErrorMessage; @@ -810,15 +803,12 @@ bool _inferGoTaskSuccess(Map result) { if (result.containsKey('error')) { return false; } - final status = _firstNestedGoTaskString( - result, - const >[ - ['status'], - ['details', 'status'], - ['payload', 'status'], - ['result', 'status'], - ], - ).toLowerCase(); + final status = _firstNestedGoTaskString(result, const >[ + ['status'], + ['details', 'status'], + ['payload', 'status'], + ['result', 'status'], + ]).toLowerCase(); if (status == 'failed' || status == 'error' || status == 'artifact_missing' || @@ -826,15 +816,12 @@ bool _inferGoTaskSuccess(Map result) { status == 'canceled') { return false; } - final code = _firstNestedGoTaskString( - result, - const >[ - ['code'], - ['details', 'code'], - ['payload', 'code'], - ['result', 'code'], - ], - ).toUpperCase(); + final code = _firstNestedGoTaskString(result, const >[ + ['code'], + ['details', 'code'], + ['payload', 'code'], + ['result', 'code'], + ]).toUpperCase(); if (code == 'OPENCLAW_ARTIFACT_MISSING' || code == 'OPENCLAW_NO_EXPORTED_ARTIFACTS' || code == 'ARTIFACT_MISSING') { diff --git a/test/runtime/assistant_execution_target_test.dart b/test/runtime/assistant_execution_target_test.dart index 0a23a48d..928822ed 100644 --- a/test/runtime/assistant_execution_target_test.dart +++ b/test/runtime/assistant_execution_target_test.dart @@ -899,8 +899,7 @@ void main() { try { await storeRoot.delete(recursive: true); } on FileSystemException { - // Temp cleanup is best effort here. The controller may still be - // releasing files when teardown starts. + // The controller may still be releasing files when teardown starts. } } }); @@ -980,7 +979,7 @@ void main() { try { await storeRoot.delete(recursive: true); } on FileSystemException { - // Temp cleanup is best effort here. + // Ignore temp cleanup failure during teardown. } } }); @@ -1047,8 +1046,7 @@ void main() { try { await storeRoot.delete(recursive: true); } on FileSystemException { - // Temp cleanup is best effort here. The controller may still be - // releasing files when teardown starts. + // The controller may still be releasing files when teardown starts. } } }); @@ -1962,8 +1960,6 @@ void main() { }, ); - - test( 'sendChatMessage hides OpenClaw artifact guard text from failed results and streaming', () async { @@ -3274,7 +3270,10 @@ void main() { await fakeGoTaskService.waitForRequestCount(prompts.length); expect(fakeGoTaskService.requests, hasLength(prompts.length)); - expect(controller.openClawGatewayActiveTurnsInternal.length, prompts.length); + expect( + controller.openClawGatewayActiveTurnsInternal.length, + prompts.length, + ); expect(controller.openClawGatewayQueuedTurnsInternal, isEmpty); for (var index = 0; index < prompts.length; index += 1) { final sessionKey = 'openclaw-e2e-$index'; @@ -4114,6 +4113,92 @@ void main() { ); }); + test( + 'OpenClaw terminal snapshot without required artifacts does not stay running', + () async { + final fakeGoTaskService = _RecordingGoTaskServiceClient() + ..outcomes.add( + const GoTaskServiceResult( + success: true, + message: '', + turnId: 'turn-openclaw-missing-screenshot', + raw: { + 'success': true, + 'status': 'running', + 'sessionId': 'openclaw-missing-screenshot', + 'threadId': 'openclaw-missing-screenshot', + 'turnId': 'turn-openclaw-missing-screenshot', + 'runId': 'run-openclaw-missing-screenshot', + 'artifactScope': + 'tasks/openclaw-missing-screenshot/run-openclaw-missing-screenshot', + 'artifactDirectory': + '/tmp/tasks/openclaw-missing-screenshot/run-openclaw-missing-screenshot', + 'gatewayProviderId': 'openclaw', + 'runtimeBudgetMinutes': 1, + 'requiredArtifactExtensions': ['.png'], + }, + errorMessage: '', + resolvedModel: '', + route: GoTaskServiceRoute.externalAcpSingle, + ), + ) + ..taskOutcomes.add( + const GoTaskServiceResult( + success: true, + message: 'gateway completed the screenshot task', + turnId: 'turn-openclaw-missing-screenshot', + raw: { + 'success': true, + 'status': 'completed', + 'turnId': 'turn-openclaw-missing-screenshot', + 'runId': 'run-openclaw-missing-screenshot', + 'output': 'gateway completed the screenshot task', + }, + errorMessage: '', + resolvedModel: '', + route: GoTaskServiceRoute.externalAcpSingle, + ), + ); + final controller = _connectedGatewayController(fakeGoTaskService); + addTearDown(controller.dispose); + + await _selectGatewaySession(controller, 'openclaw-missing-screenshot'); + + await expectLater( + controller + .sendChatMessage('执行截图并导出 PNG') + .timeout(const Duration(milliseconds: 500)), + completes, + ); + await _waitForThreadLifecycleStatusWithin( + controller, + 'openclaw-missing-screenshot', + 'ready', + const Duration(milliseconds: 500), + ); + await _waitForThreadArtifactSyncStatusWithin( + controller, + 'openclaw-missing-screenshot', + 'no-exported-artifacts', + const Duration(milliseconds: 500), + ); + + final thread = controller.requireTaskThreadForSessionInternal( + 'openclaw-missing-screenshot', + ); + expect(thread.lifecycleState.status, 'ready'); + expect(thread.lifecycleState.lastResultCode, 'success'); + expect(thread.lastArtifactSyncStatus, 'no-exported-artifacts'); + expect(thread.openClawTaskAssociation, isNull); + expect( + controller.assistantSessionHasPendingRun( + 'openclaw-missing-screenshot', + ), + isFalse, + ); + }, + ); + test( 'sendChatMessage resumes existing interrupted and error states', () async { @@ -4687,7 +4772,21 @@ Future _waitForThreadLifecycleStatus( String sessionKey, String status, ) async { - final deadline = DateTime.now().add(const Duration(seconds: 15)); + await _waitForThreadLifecycleStatusWithin( + controller, + sessionKey, + status, + const Duration(seconds: 15), + ); +} + +Future _waitForThreadLifecycleStatusWithin( + AppController controller, + String sessionKey, + String status, + Duration timeout, +) async { + final deadline = DateTime.now().add(timeout); while (DateTime.now().isBefore(deadline)) { final currentStatus = controller .taskThreadForSessionInternal(sessionKey) @@ -4707,6 +4806,30 @@ Future _waitForThreadLifecycleStatus( ); } +Future _waitForThreadArtifactSyncStatusWithin( + AppController controller, + String sessionKey, + String status, + Duration timeout, +) async { + final deadline = DateTime.now().add(timeout); + while (DateTime.now().isBefore(deadline)) { + final currentStatus = controller + .taskThreadForSessionInternal(sessionKey) + ?.lastArtifactSyncStatus; + if (currentStatus == status) { + return; + } + await Future.delayed(const Duration(milliseconds: 10)); + } + final currentStatus = controller + .taskThreadForSessionInternal(sessionKey) + ?.lastArtifactSyncStatus; + throw StateError( + 'Timed out waiting for $sessionKey artifact sync status $status. Current status: $currentStatus.', + ); +} + Future _waitForThreadLastResultCode( AppController controller, String sessionKey, @@ -4741,8 +4864,6 @@ class _RecordingGoTaskServiceClient implements GoTaskServiceClient { final List taskOutcomes = []; Future Function(GoTaskServiceRequest request)? onExecuteTask; - - @override Future executeTask( GoTaskServiceRequest request, { @@ -4814,8 +4935,6 @@ class _RecordingGoTaskServiceClient implements GoTaskServiceClient { OpenClawTaskAssociation? association, }) async {} - - @override Future dispose() async {} } @@ -4831,8 +4950,6 @@ class _BlockingGoTaskServiceClient implements GoTaskServiceClient { final Map _updates = {}; - - @override Future executeTask( GoTaskServiceRequest request, { @@ -4937,8 +5054,6 @@ class _BlockingGoTaskServiceClient implements GoTaskServiceClient { cancelledSessionIds.add(sessionId); } - - @override Future dispose() async {} } diff --git a/test/runtime/gateway_acp_client_auth_test.dart b/test/runtime/gateway_acp_client_auth_test.dart index dd112714..95375752 100644 --- a/test/runtime/gateway_acp_client_auth_test.dart +++ b/test/runtime/gateway_acp_client_auth_test.dart @@ -67,7 +67,7 @@ void main() { expect(result.message, 'content list response'); }); - test('uses bridge failure text instead of empty output fallback', () { + test('uses bridge failure text when output is empty', () { final result = goTaskServiceResultFromAcpResponse({ 'jsonrpc': '2.0', 'id': 'request-id', @@ -251,7 +251,6 @@ void main() { 'https://xworkmate-bridge.svc.plus/artifacts/summary.pdf', ); }); - }); group('GatewayAcpClient authorization', () { @@ -559,7 +558,7 @@ void main() { ); test( - 'recovers OpenClaw task result from completed session update when final SSE envelope is lost', + 'does not synthesize OpenClaw result from completed session update when final SSE envelope is lost', () async { final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); addTearDown(() => server.close(force: true)); @@ -614,29 +613,33 @@ void main() { ); addTearDown(transport.dispose); - final result = await transport.executeTask( - const GoTaskServiceRequest( - sessionId: 'unit-fixture-task-a', - threadId: 'unit-fixture-task-a', - target: AssistantExecutionTarget.gateway, - provider: SingleAgentProvider.openclaw, - prompt: 'create files', - workingDirectory: '/tmp/workspace', - model: '', - thinking: 'off', - selectedSkills: [], - inlineAttachments: [], - localAttachments: [], - agentId: '', - metadata: {}, + await expectLater( + transport.executeTask( + const GoTaskServiceRequest( + sessionId: 'unit-fixture-task-a', + threadId: 'unit-fixture-task-a', + target: AssistantExecutionTarget.gateway, + provider: SingleAgentProvider.openclaw, + prompt: 'create files', + workingDirectory: '/tmp/workspace', + model: '', + thinking: 'off', + selectedSkills: [], + inlineAttachments: [], + localAttachments: [], + agentId: '', + metadata: {}, + ), + onUpdate: (_) {}, + ), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'ACP_HTTP_CONNECTION_CLOSED', + ), ), - onUpdate: (_) {}, ); - - expect(result.success, isTrue); - expect(result.message, 'stable completed output'); - expect(result.artifacts, hasLength(1)); - expect(result.artifacts.single.relativePath, 'exports/final.md'); }, ); @@ -1010,7 +1013,8 @@ void main() { 'items': >[ { 'relativePath': 'exports/snapshot.md', - 'downloadUrl': 'https://xworkmate-bridge.svc.plus/artifacts/openclaw/download?sessionKey=unit-fixture-task-b&runId=turn-recovered-running&relativePath=exports%2Fsnapshot.md', + 'downloadUrl': + 'https://xworkmate-bridge.svc.plus/artifacts/openclaw/download?sessionKey=unit-fixture-task-b&runId=turn-recovered-running&relativePath=exports%2Fsnapshot.md', 'contentType': 'text/markdown', 'sizeBytes': 64, }, @@ -1084,8 +1088,7 @@ void main() { 'event': 'running', 'status': 'running', 'runId': 'run-running', - 'artifactScope': - 'tasks/unit-fixture-task-handle/run-running', + 'artifactScope': 'tasks/unit-fixture-task-handle/run-running', 'artifactDirectory': '/home/ubuntu/.openclaw/workspace/tasks/unit-fixture-task-handle/run-running', 'gatewayProviderId': 'openclaw', @@ -1461,8 +1464,7 @@ void main() { try { await storeRoot.delete(recursive: true); } on FileSystemException { - // Temp cleanup is best effort here. The controller does not own - // the lifecycle of the OS temp directory. + // The controller does not own the OS temp directory lifecycle. } } }); @@ -1518,8 +1520,7 @@ void main() { try { await storeRoot.delete(recursive: true); } on FileSystemException { - // Temp cleanup is best effort here. The client may still be - // releasing files when teardown starts. + // The client may still be releasing files when teardown starts. } } }); @@ -1561,8 +1562,7 @@ void main() { try { await storeRoot.delete(recursive: true); } on FileSystemException { - // Temp cleanup is best effort here. The controller may still be - // releasing files when teardown starts. + // The controller may still be releasing files when teardown starts. } } }); @@ -1617,18 +1617,17 @@ void main() { ); test( - 'desktop bridge auth resolver does not fallback to the remote gateway token for bridge ACP', + 'desktop bridge auth resolver rejects the remote gateway token for bridge ACP', () async { final storeRoot = await Directory.systemTemp.createTemp( - 'xworkmate-acp-auth-bridge-fallback-', + 'xworkmate-acp-auth-bridge-reject-', ); addTearDown(() async { if (await storeRoot.exists()) { try { await storeRoot.delete(recursive: true); } on FileSystemException { - // Temp cleanup is best effort here. The controller may still be - // releasing files when teardown starts. + // The controller may still be releasing files when teardown starts. } } });