fix: surface queued task feedback

This commit is contained in:
Haitao Pan 2026-05-19 10:28:13 +08:00
parent 484b10ecf5
commit 208545dd71
9 changed files with 375 additions and 13 deletions

View File

@ -76,5 +76,6 @@ All download buttons currently point to the latest GitHub release page.
- [Changelog](/Users/shenlan/workspaces/cloud-neutral-toolkit/XWorkmate.svc.plus/docs/releases/xworkmate-changelog.md)
- [Feature Matrix](/Users/shenlan/workspaces/cloud-neutral-toolkit/XWorkmate.svc.plus/docs/plans/xworkmate-ui-feature-matrix.md)
- [Roadmap](/Users/shenlan/workspaces/cloud-neutral-toolkit/XWorkmate.svc.plus/docs/plans/xworkmate-ui-feature-roadmap.md)
- [Cross-Repo Task State Workflow](docs/architecture/cross-repo-task-state-workflow.md)
- [Gateway Dev Runbook](/Users/shenlan/workspaces/cloud-neutral-toolkit/XWorkmate.svc.plus/docs/runbooks/gateway-dev-runbook.md)
- [Security Rules](/Users/shenlan/workspaces/cloud-neutral-toolkit/XWorkmate.svc.plus/docs/security/secure-development-rules.md)

View File

@ -0,0 +1,196 @@
# Cross-Repo Task State Workflow
This document records the task-state workflow across:
- `xworkmate-app`
- `xworkmate-bridge`
- `openclaw-multi-session-plugins`
The core ownership split is:
- `xworkmate-app` owns task UI state, `TaskThread` persistence, local thread workspaces, and the OpenClaw submit queue.
- `xworkmate-bridge` owns public session/routing contracts, provider compatibility, normalized results, and OpenClaw task-submit routing.
- `openclaw-multi-session-plugins` owns OpenClaw task-scoped artifact preparation, export, and artifact-scope isolation.
## Overall Flow
```mermaid
flowchart LR
U["User input / follow-up"] --> APP["xworkmate-app<br/>TaskThread + UI state"]
APP -->|session.start / session.message| BR["xworkmate-bridge<br/>/acp/rpc or /gateway/openclaw"]
BR -->|xworkmate.routing.resolve| ROUTE{"Execution target"}
ROUTE -->|single-agent| AG["codex / opencode / gemini / hermes"]
ROUTE -->|gateway=openclaw| OC["OpenClaw Gateway Runtime"]
OC --> PLUG["openclaw-multi-session-plugins<br/>task-scoped artifacts"]
PLUG -->|artifactRef / files / downloadUrl| BR
BR -->|normalized result / SSE update| APP
APP -->|write files| LOCAL["$HOME/.xworkmate/threads/<session>"]
APP -->|persist index| STORE["~/Library/Application Support/xworkmate/tasks/threads.json"]
```
## App TaskThread State Machine
`TaskThread.lifecycleState.status` is intentionally small. Most terminal outcomes return to `ready`; the specific terminal result is stored in `lastResultCode`.
```mermaid
stateDiagram-v2
[*] --> Ready: create task / restore persisted task
Ready --> Queued: OpenClaw gateway active slot is full
Queued --> Running: drainOpenClawGatewayQueue
Queued --> Ready: abortRun\nlastResultCode=aborted
Queued --> Ready: queue full\nlastResultCode=OPENCLAW_GATEWAY_QUEUE_FULL
Ready --> Running: sendChatMessage\nmarkGatewayChatRun
Running --> Ready: GoTaskServiceResult.success=true\nlastResultCode=success
Running --> Ready: result.status / result.code\nlastResultCode=<status|code>
Running --> Ready: no displayable output\nlastResultCode=failed
Running --> Ready: ACP interrupted / timeout\nlastResultCode=ACP_*
Running --> Ready: abortRun\nlastResultCode=aborted
Ready --> Archived: user archives task
Archived --> Ready: user restores task
note right of Ready
lifecycleStatus terminal state is usually ready.
Read lastResultCode for result detail:
success / failed / error / aborted /
artifact_missing / ACP_HTTP_*
end note
```
## Task Workspace Context Injection
Every app-owned task has a local workspace under `$HOME/.xworkmate/threads/<session>`. For remote execution, the bridge/runtime may also resolve a remote task workspace hint. The app passes the task workspace in two ways:
- Structured request fields: `workingDirectory` and, when available, `remoteWorkingDirectoryHint`.
- External conversation context: a `TaskThread workspace context` prefix is added to the prompt sent to Bridge/OpenClaw.
The local chat transcript still stores the user's original text. Only the external task prompt is enriched, so the UI does not show internal workspace rules as user content.
```mermaid
sequenceDiagram
participant UI as "XWorkmate UI"
participant APP as "TaskThread runtime"
participant BR as "Bridge session"
participant OC as "OpenClaw / provider"
participant WS as "Task workspace"
UI->>APP: "user message"
APP->>WS: "ensure $HOME/.xworkmate/threads/<session>"
APP->>APP: "build TaskThread workspace context"
APP->>BR: "taskPrompt + workingDirectory + remoteWorkingDirectoryHint"
BR->>OC: "session.start / session.message"
OC->>WS: "final files must be exported into task scope"
BR-->>APP: "result + artifact refs"
APP->>WS: "sync inline/downloaded artifacts"
```
Prompt-level workspace rules are deliberately strict. `remoteWorkingDirectoryHint` is the writable task workspace for remote OpenClaw/provider execution when present; otherwise `workingDirectory` is used.
- Treat the current task workspace as the only writable workspace for the task execution.
- Create, modify, and export task files inside that workspace or its task artifact scope.
- Do not use global OpenClaw media/cache paths, `/tmp`, Downloads, Desktop, or other arbitrary directories as final deliverable locations.
- If a tool creates output outside the task workspace, copy/export the final deliverables into the task workspace before claiming completion.
- Prefer local task-workspace paths, or paths relative to that workspace, when reporting files back to the user.
## OpenClaw Gateway Queue
The app serializes OpenClaw gateway execution locally because OpenClaw task execution is treated as a constrained gateway lane.
```mermaid
flowchart TD
A["APP selects Gateway + OpenClaw"] --> B{"active < 1 ?"}
B -->|yes| C["Run immediately<br/>lifecycleStatus=running"]
B -->|no| D{"queue < 20 ?"}
D -->|yes| E["Enqueue<br/>lifecycleStatus=queued<br/>lastArtifactSyncStatus=queued"]
D -->|no| F["Queue full<br/>lastResultCode=OPENCLAW_GATEWAY_QUEUE_FULL"]
E --> G["drain queue"]
G --> C
C --> H["Bridge /gateway/openclaw"]
H --> I["OpenClaw execution"]
I --> J{"Result"}
J -->|success + output/files| K["APP ready<br/>lastResultCode=success<br/>sync artifacts"]
J -->|artifact guard| L["APP ready<br/>lastResultCode=artifact_missing"]
J -->|failure/interruption| M["APP ready<br/>lastResultCode=failed/error/ACP_*"]
```
## Bridge Session And Routing Workflow
The bridge exposes one public session contract while keeping provider-specific behavior behind compatibility layers. `/gateway/openclaw` is a narrow OpenClaw task-submit lane, not a general ACP base endpoint.
```mermaid
flowchart TD
REQ["APP request"] --> EP{"HTTP / WS entry"}
EP -->|/acp or /acp/rpc| RPC["General JSON-RPC"]
EP -->|/gateway/openclaw| GW["OpenClaw task-submit endpoint"]
RPC --> METHOD{"method"}
METHOD -->|acp.capabilities| CAP["Return agent providers + gatewayProviders=openclaw"]
METHOD -->|xworkmate.routing.resolve| ROUTE["Resolve single-agent / gateway"]
METHOD -->|session.start| START["Create / start session turn"]
METHOD -->|session.message| MSG["Continue existing session"]
METHOD -->|session.cancel / session.close| CTRL["Cancel / close session"]
GW --> GUARD{"method is session.start / session.message ?"}
GUARD -->|no| REJECT["Reject: not a global ACP base"]
GUARD -->|yes| FORCE["Force routing=gateway/openclaw<br/>Reject multiAgent"]
FORCE --> START
START --> ORCH["session_orchestrator"]
MSG --> ORCH
ORCH --> PROVIDER{"provider compat"}
PROVIDER -->|single-agent| SA["codex / opencode / gemini / hermes"]
PROVIDER -->|gateway| OCG["OpenClaw runtime"]
PROVIDER -->|multi-agent via /acp/rpc| MA["multi-agent orchestration"]
SA --> NORM["normalized result"]
OCG --> NORM
MA --> NORM
NORM --> OUT["success / status / turnId / output / artifacts / resolved*"]
```
## OpenClaw Plugin Artifact Scope
OpenClaw artifacts are scoped by task session and run. This prevents one task or turn from borrowing files from another.
```mermaid
flowchart TD
CTX["OpenClaw plugin context<br/>sessionKey + runId + workspaceDir"] --> PREP["prepareXWorkmateArtifacts"]
PREP --> SCOPE["artifactScope = tasks/<sessionKey>/<runId>"]
SCOPE --> DIR["artifactDirectory"]
DIR --> RUN["OpenClaw writes files"]
RUN --> EXPORT["exportXWorkmateArtifacts"]
EXPORT --> VALIDATE{"scope matches sessionKey/runId ?"}
VALIDATE -->|no| ERR["Reject cross-task / cross-run artifact"]
VALIDATE -->|yes| MANIFEST["manifest + artifactRef + files"]
MANIFEST --> BR["Bridge result"]
BR --> APP["APP downloads or inlines into local thread workspace"]
note right of SCOPE
Concurrent task isolation is based on:
tasks/<session>/<run>
Do not reuse other sessions or previous run files.
end note
```
## Status Field Mapping
```mermaid
flowchart LR
APP1["APP TaskThread.lifecycleState.status"] --> A1["queued / running / ready / archived"]
APP2["APP TaskThread.lifecycleState.lastResultCode"] --> A2["queued / running / success / failed / error / aborted / artifact_missing / ACP_HTTP_*"]
APP3["APP lastArtifactSyncStatus"] --> A3["queued / running / synced / no-artifacts / failed"]
BR1["Bridge result.status"] --> B1["available / success / failed / provider status"]
BR2["Bridge result.success"] --> B2["true / false"]
PL1["Plugin artifact export"] --> P1["scopeKind=task<br/>artifactScope=tasks/session/run"]
```
## Boundary Rules
- The app does not store OpenClaw URLs. It only consumes bridge capabilities where `gatewayProviders` includes `openclaw`.
- `/gateway/openclaw` is only for OpenClaw `session.start` and `session.message`; it is not a global ACP endpoint.
- Follow-up conversation uses the same `sessionKey` / `threadId`. Bridge `session.message` must continue the provider session state or return a structured continuation error.
- Artifact ownership is enforced by `openclaw-multi-session-plugins` with `tasks/<session>/<run>` scope. The app syncs only the current run's artifacts into the local thread workspace.
- Upgrade/install flows must preserve real local history. Cleanup must only remove explicitly known test-pollution session keys.

View File

@ -67,6 +67,8 @@ extension AppControllerDesktopThreadActions on AppController {
bool assistantSessionHasPendingRun(String sessionKey) {
final normalized = normalizedAssistantSessionKeyInternal(sessionKey);
return aiGatewayPendingSessionKeysInternal.contains(normalized) ||
(openClawGatewayQueuedTurnsBySessionInternal[normalized]?.isNotEmpty ??
false) ||
(multiAgentRunPendingInternal &&
matchesSessionKey(
normalized,
@ -417,11 +419,21 @@ extension AppControllerDesktopThreadActions on AppController {
required String agentId,
required Map<String, dynamic> metadata,
required bool resumeSessionHint,
bool appendUserTurn = true,
}) async {
final resumeSession =
resumeSessionHint ||
shouldResumeGatewaySessionForNextSendInternal(sessionKey);
appendGatewayUserTurnInternal(sessionKey, message);
(appendUserTurn &&
shouldResumeGatewaySessionForNextSendInternal(sessionKey));
final taskPrompt = taskWorkspaceContextPromptInternal(
sessionKey: sessionKey,
userPrompt: message,
workingDirectory: workingDirectory,
remoteWorkingDirectoryHint: remoteWorkingDirectoryHint,
);
if (appendUserTurn) {
appendGatewayUserTurnInternal(sessionKey, message);
}
markGatewayChatRunInternal(sessionKey);
try {
final result = await goTaskServiceClientInternal.executeTask(
@ -430,7 +442,7 @@ extension AppControllerDesktopThreadActions on AppController {
threadId: sessionKey,
target: target,
provider: provider,
prompt: message,
prompt: taskPrompt,
workingDirectory: workingDirectory,
remoteWorkingDirectoryHint: remoteWorkingDirectoryHint,
model: model,
@ -482,6 +494,53 @@ extension AppControllerDesktopThreadActions on AppController {
}
}
String taskWorkspaceContextPromptInternal({
required String sessionKey,
required String userPrompt,
required String workingDirectory,
required String remoteWorkingDirectoryHint,
}) {
final requestText = userPrompt.trim().isEmpty
? 'See attached.'
: userPrompt.trim();
final buffer = StringBuffer()
..writeln('TaskThread workspace context:')
..writeln('- sessionKey: $sessionKey')
..writeln('- localWorkspace: ${workingDirectory.trim()}');
final remoteHint = remoteWorkingDirectoryHint.trim();
if (remoteHint.isNotEmpty) {
buffer.writeln('- remoteWorkspaceHint: $remoteHint');
}
buffer.writeln(
'- currentTaskWorkspace: ${remoteHint.isNotEmpty ? remoteHint : workingDirectory.trim()}',
);
buffer
..writeln()
..writeln('Workspace isolation rules:')
..writeln(
'1. Treat currentTaskWorkspace as the only writable workspace for this TaskThread execution.',
)
..writeln(
'2. Create, modify, and export task files inside currentTaskWorkspace or its task artifact scope.',
)
..writeln(
'3. Do not use arbitrary global directories, OpenClaw media cache, Downloads, Desktop, or /tmp as final deliverable locations.',
)
..writeln(
'4. If a tool creates output outside currentTaskWorkspace, copy or export the final deliverables into currentTaskWorkspace before claiming completion.',
)
..writeln(
'5. When reporting files, prefer paths inside currentTaskWorkspace or paths relative to currentTaskWorkspace.',
)
..writeln(
'6. The app syncs final artifacts from currentTaskWorkspace back into localWorkspace.',
)
..writeln()
..writeln('User request:')
..write(requestText);
return buffer.toString();
}
bool usesOpenClawGatewayQueueInternal(
AssistantExecutionTarget target,
SingleAgentProvider provider,
@ -493,6 +552,7 @@ extension AppControllerDesktopThreadActions on AppController {
Future<void> enqueueOpenClawGatewayTurnInternal(
OpenClawGatewayQueuedTurnInternal turn,
) async {
appendGatewayUserTurnInternal(turn.sessionKey, turn.message);
if (openClawGatewayActiveTasksInternal >=
openClawGatewayMaxActiveTasksInternal &&
openClawGatewayQueuedTurnsInternal.length >=
@ -632,6 +692,7 @@ extension AppControllerDesktopThreadActions on AppController {
agentId: turn.agentId,
metadata: turn.metadata,
resumeSessionHint: turn.resumeSessionHint,
appendUserTurn: false,
),
);
} catch (error) {

View File

@ -160,12 +160,18 @@ Future<void> runMultiAgentCollaborationThreadSessionInternal(
);
controller.recomputeTasksInternal();
try {
final taskPrompt = controller.taskWorkspaceContextPromptInternal(
sessionKey: sessionKey,
userPrompt: composedPrompt,
workingDirectory: workingDirectory,
remoteWorkingDirectoryHint: remoteWorkingDirectoryHint?.trim() ?? '',
);
final result = await controller.goTaskServiceClientInternal.executeTask(
GoTaskServiceRequest(
sessionId: sessionKey,
threadId: sessionKey,
target: controller.assistantExecutionTargetForSession(sessionKey),
prompt: composedPrompt,
prompt: taskPrompt,
workingDirectory: workingDirectory,
remoteWorkingDirectoryHint: remoteWorkingDirectoryHint?.trim() ?? '',
model: controller.assistantModelForSession(sessionKey),

View File

@ -344,6 +344,9 @@ String sessionStatusInternal(
if (session.abortedLastRun == true) {
return 'failed';
}
if (normalizedLifecycle == 'queued') {
return 'queued';
}
if (sessionPending) {
return 'running';
}

View File

@ -2,7 +2,13 @@ import 'package:flutter/material.dart';
import '../i18n/app_language.dart';
enum AssistantTaskProgressPhase { idle, running, syncingArtifacts, interrupted }
enum AssistantTaskProgressPhase {
idle,
queued,
running,
syncingArtifacts,
interrupted,
}
class AssistantTaskProgressState {
const AssistantTaskProgressState({
@ -26,6 +32,7 @@ class AssistantTaskProgressState {
bool get visible => phase != AssistantTaskProgressPhase.idle;
bool get interrupted => phase == AssistantTaskProgressPhase.interrupted;
bool get running =>
phase == AssistantTaskProgressPhase.queued ||
phase == AssistantTaskProgressPhase.running ||
phase == AssistantTaskProgressPhase.syncingArtifacts;
}
@ -146,6 +153,15 @@ AssistantTaskProgressState assistantTaskProgressState({
final budget = runtimeBudgetMinutes == null || runtimeBudgetMinutes <= 0
? null
: runtimeBudgetMinutes;
final result = lastResultCode.trim().toUpperCase();
if (status == 'queued' || syncStatus == 'queued' || result == 'QUEUED') {
return AssistantTaskProgressState(
phase: AssistantTaskProgressPhase.queued,
label: appText('任务已排队,等待执行...', 'Task queued, waiting to run...'),
value: 0.18,
runtimeBudgetMinutes: budget,
);
}
if (pending && syncStatus == 'syncing') {
return AssistantTaskProgressState(
phase: AssistantTaskProgressPhase.syncingArtifacts,
@ -161,7 +177,6 @@ AssistantTaskProgressState assistantTaskProgressState({
runtimeBudgetMinutes: budget,
);
}
final result = lastResultCode.trim().toUpperCase();
if (status == 'interrupted' || syncStatus == 'interrupted') {
return AssistantTaskProgressState(
phase: AssistantTaskProgressPhase.interrupted,

View File

@ -2,9 +2,9 @@ name: xworkmate
description: "XWorkmate desktop-first AI workspace shell."
publish_to: 'none'
version: 1.0.0-beta.2+5
build-date: 2026-05-18
build-id: e7e1135
version: 1.0.0-beta.2+6
build-date: 2026-05-19
build-id: 0f5440d
environment:
sdk: ^3.11.0

View File

@ -57,6 +57,32 @@ void main() {
expect(indicator.value, 0.82);
});
testWidgets('shows queued progress while a task waits to run', (
tester,
) async {
await tester.pumpWidget(
_buildTestApp(
assistantTaskProgressState(
pending: true,
lifecycleStatus: 'queued',
lastResultCode: 'queued',
artifactSyncStatus: 'queued',
),
onStop: () {},
),
);
expect(find.text('任务已排队,等待执行...'), findsOneWidget);
expect(
find.byKey(const Key('assistant-task-progress-stop-button')),
findsOneWidget,
);
final indicator = tester.widget<LinearProgressIndicator>(
find.byKey(const Key('assistant-task-progress-indicator')),
);
expect(indicator.value, 0.18);
});
testWidgets('shows interrupted state after ACP connection closes', (
tester,
) async {

View File

@ -898,7 +898,21 @@ void main() {
await controller.sendChatMessage('first turn');
expect(fakeGoTaskService.requests, hasLength(1));
expect(fakeGoTaskService.requests.single.resumeSession, isFalse);
final request = fakeGoTaskService.requests.single;
expect(request.resumeSession, isFalse);
expect(request.prompt, contains('TaskThread workspace context:'));
expect(request.prompt, contains('- sessionKey: unit-fixture-task-a'));
expect(request.prompt, contains(request.workingDirectory));
expect(request.prompt, contains(request.remoteWorkingDirectoryHint));
expect(request.prompt, contains('User request:\nfirst turn'));
expect(
controller.chatMessages.map((message) => message.text),
contains('first turn'),
);
expect(
controller.chatMessages.map((message) => message.text).join('\n'),
isNot(contains('TaskThread workspace context:')),
);
});
test(
@ -1632,11 +1646,15 @@ void main() {
final taskBRequest = fakeGoTaskService.requests[1];
expect(taskARequest.sessionId, sessionA);
expect(taskBRequest.sessionId, sessionB);
expect(taskARequest.prompt, taskBRequest.prompt);
expect(taskARequest.prompt, isNot(taskBRequest.prompt));
expect(taskARequest.prompt, contains(prompt));
expect(taskBRequest.prompt, contains(prompt));
expect(taskARequest.resumeSession, isFalse);
expect(taskBRequest.resumeSession, isFalse);
expect(taskARequest.workingDirectory, endsWith('/$sessionA'));
expect(taskBRequest.workingDirectory, endsWith('/$sessionB'));
expect(taskARequest.prompt, contains(taskARequest.workingDirectory));
expect(taskBRequest.prompt, contains(taskBRequest.workingDirectory));
expect(
taskARequest.workingDirectory,
isNot(taskBRequest.workingDirectory),
@ -2192,6 +2210,26 @@ void main() {
.status,
'queued',
);
expect(
controller.assistantSessionHasPendingRun('queue-task-b'),
isTrue,
);
expect(
controller.assistantSessionHasPendingRun('queue-task-c'),
isTrue,
);
expect(
controller.localSessionMessagesInternal['queue-task-b']!.map(
(message) => message.text,
),
contains('same prompt'),
);
expect(
controller.localSessionMessagesInternal['queue-task-c']!.map(
(message) => message.text,
),
contains('different prompt'),
);
fakeGoTaskService.complete(
'queue-task-a',
@ -2214,9 +2252,12 @@ void main() {
final taskBRequest = fakeGoTaskService.requests[1];
expect(taskBRequest.sessionId, 'queue-task-b');
expect(taskBRequest.prompt, 'same prompt');
expect(taskBRequest.prompt, contains('TaskThread workspace context:'));
expect(taskBRequest.prompt, contains('- sessionKey: queue-task-b'));
expect(taskBRequest.prompt, contains('User request:\nsame prompt'));
expect(taskBRequest.resumeSession, isFalse);
expect(taskBRequest.workingDirectory, endsWith('/queue-task-b'));
expect(taskBRequest.prompt, contains(taskBRequest.workingDirectory));
expect(
taskBRequest.remoteWorkingDirectoryHint,
endsWith('/threads/queue-task-b'),
@ -2239,12 +2280,25 @@ void main() {
'queue-task-b',
'ready',
);
expect(
controller.localSessionMessagesInternal['queue-task-b']!
.where(
(message) =>
message.role == 'user' && message.text == 'same prompt',
)
.length,
1,
);
await fakeGoTaskService.waitForRequestCount(3);
final taskCRequest = fakeGoTaskService.requests[2];
expect(taskCRequest.sessionId, 'queue-task-c');
expect(taskCRequest.prompt, 'different prompt');
expect(
taskCRequest.prompt,
contains('User request:\ndifferent prompt'),
);
expect(taskCRequest.workingDirectory, endsWith('/queue-task-c'));
expect(taskCRequest.prompt, contains(taskCRequest.workingDirectory));
fakeGoTaskService.complete(
'queue-task-c',
const GoTaskServiceResult(