diff --git a/docs/config/server-access.mdx b/docs/config/server-access.mdx
index 05aaba727f8..18f8f6dfeb7 100644
--- a/docs/config/server-access.mdx
+++ b/docs/config/server-access.mdx
@@ -94,6 +94,37 @@ After activation, the server exits gracefully and the supervisor relaunches it.
configure unlimited restarts (`max_restart_attempts=0`) before relying on self-update.
+## Running the desktop app and `xum server` together
+
+If `xum server` is already running when you open the desktop app on the same machine and Xum data directory, the desktop app does not start a second API server. It still runs its own full backend on the same data: both processes read and write the same projects, workspaces, chat history, and tasks.
+
+What stays consistent between the two:
+
+- Config changes, such as creating a workspace or changing a setting, are written under a shared lock.
+- Chat history appends are written under a shared lock.
+- A workspace turn that one process runs is never marked interrupted by the other while the first process is alive.
+- A consent change you make in one app wins over a default that the other app was about to apply.
+
+What does not work across the two:
+
+- Streams, Stop, and message queues belong to the process that started them. Stopping a turn in one app does not stop a turn that the other app runs.
+- Busy state and delegated-turn checks are per process. The other app can admit a message into a workspace that looks busy only in the first app.
+- An open chat or sidebar does not update when the other app writes. Reload the app or reopen the workspace to see the other app's messages and changes.
+
+
+ Do not drive the same workspace or task from both apps at the same time. Pick one app per
+ workspace.
+
+
+
+ When one of the two processes starts, it recovers the sub-agent tasks that were starting, running,
+ or awaiting a report, even if the other process is still running them. That can start a second
+ copy of a task while the first copy keeps running. Start the second app only when no sub-agent
+ tasks are active.
+
+
+Running two different Xum versions on the same data directory is not supported.
+
## Related
- [CLI reference](/reference/cli)
diff --git a/src/node/services/agentSkills/builtInSkillContent.generated.ts b/src/node/services/agentSkills/builtInSkillContent.generated.ts
index 736b3b2fe6f..422db337cda 100644
--- a/src/node/services/agentSkills/builtInSkillContent.generated.ts
+++ b/src/node/services/agentSkills/builtInSkillContent.generated.ts
@@ -5317,6 +5317,37 @@ export const BUILTIN_SKILL_FILES: Record> = {
" configure unlimited restarts (`max_restart_attempts=0`) before relying on self-update.",
"",
"",
+ "## Running the desktop app and `xum server` together",
+ "",
+ "If `xum server` is already running when you open the desktop app on the same machine and Xum data directory, the desktop app does not start a second API server. It still runs its own full backend on the same data: both processes read and write the same projects, workspaces, chat history, and tasks.",
+ "",
+ "What stays consistent between the two:",
+ "",
+ "- Config changes, such as creating a workspace or changing a setting, are written under a shared lock.",
+ "- Chat history appends are written under a shared lock.",
+ "- A workspace turn that one process runs is never marked interrupted by the other while the first process is alive.",
+ "- A consent change you make in one app wins over a default that the other app was about to apply.",
+ "",
+ "What does not work across the two:",
+ "",
+ "- Streams, Stop, and message queues belong to the process that started them. Stopping a turn in one app does not stop a turn that the other app runs.",
+ "- Busy state and delegated-turn checks are per process. The other app can admit a message into a workspace that looks busy only in the first app.",
+ "- An open chat or sidebar does not update when the other app writes. Reload the app or reopen the workspace to see the other app's messages and changes.",
+ "",
+ "",
+ " Do not drive the same workspace or task from both apps at the same time. Pick one app per",
+ " workspace.",
+ "",
+ "",
+ "",
+ " When one of the two processes starts, it recovers the sub-agent tasks that were starting, running,",
+ " or awaiting a report, even if the other process is still running them. That can start a second",
+ " copy of a task while the first copy keeps running. Start the second app only when no sub-agent",
+ " tasks are active.",
+ "",
+ "",
+ "Running two different Xum versions on the same data directory is not supported.",
+ "",
"## Related",
"",
"- [CLI reference](/reference/cli)",
diff --git a/src/node/services/subagentAttemptSettlements.ts b/src/node/services/subagentAttemptSettlements.ts
index 7cc6196fbb2..a2ee96439ad 100644
--- a/src/node/services/subagentAttemptSettlements.ts
+++ b/src/node/services/subagentAttemptSettlements.ts
@@ -26,7 +26,8 @@ import writeFileAtomic from "@/node/utils/writeFileAtomic";
* (#4545; driving one task from two backends is unsupported, and a Stop in one backend does not
* stop the other's turn). Consumers must read the report artifact first: a receipt followed by a
* report is "reported". Once a workflow claim retires the attempt, a late report is refused, so a
- * step accepts either that report or the replacement, never both.
+ * step accepts either that report or the replacement, never both (see CONCURRENT BACKENDS in
+ * processLiveness.ts).
*
* Producers are TaskService's settlement paths (persistOwnedAttemptSettlement and the stop-record
* release). The only reader so far is the lineage proof at reawaken/reactivation; the classifier
diff --git a/src/node/services/taskService.ts b/src/node/services/taskService.ts
index c62a23d01cd..08d70311daa 100644
--- a/src/node/services/taskService.ts
+++ b/src/node/services/taskService.ts
@@ -9312,6 +9312,8 @@ export class TaskService implements AgentTaskIntegration {
const targetIsAgentTask =
coerceNonEmptyString(targetEntry.workspace.parentWorkspaceId) != null;
const unrelatedRoot = relation === "target_unrelated" && !targetIsAgentTask;
+ // Process-local courtesy: another backend's delegated turn is invisible here (#4446; see
+ // CONCURRENT BACKENDS in processLiveness.ts).
const delegatedRootUnavailable = (): boolean =>
unrelatedRoot &&
this.getWorkspaceTurnManager().getLiveWorkspaceTurnRegistration(targetId) != null;
@@ -14257,7 +14259,11 @@ export class TaskService implements AgentTaskIntegration {
);
}
- /** An on-demand root-workspace address book, separate from either ownership graph. */
+ /**
+ * An on-demand root-workspace address book, separate from either ownership graph. Activity and
+ * delegated-turn state come from this backend's memory; another backend's live work is not
+ * reflected (see CONCURRENT BACKENDS in processLiveness.ts).
+ */
listInstanceWorkspaces(
callerWorkspaceId: string,
options: { query?: string | null; limit?: number | null; offset?: number | null }
diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts
index 1bb936fd05d..6327194200b 100644
--- a/src/node/services/workspaceService.ts
+++ b/src/node/services/workspaceService.ts
@@ -3596,7 +3596,7 @@ export class WorkspaceService
* durable staging/apply state does, via refine-apply.lock). Multi-instance
* mode is a development escape hatch — concurrent turn traffic against one
* workspace from two backends is unsupported beyond those durable-state
- * locks.
+ * locks. See CONCURRENT BACKENDS in processLiveness.ts.
*/
private acquireContextMutationAdmissionGuard(
workspaceId: string,
diff --git a/src/node/utils/concurrency/processLiveness.ts b/src/node/utils/concurrency/processLiveness.ts
index 77e17d4fe87..fd55ca26d33 100644
--- a/src/node/utils/concurrency/processLiveness.ts
+++ b/src/node/utils/concurrency/processLiveness.ts
@@ -7,6 +7,31 @@
* or replaced container, rebooted host) is retired and cannot resume before
* a new one accesses the root. Concurrent cross-domain sharing is
* unsupported, not prevented.
+ *
+ * CONCURRENT BACKENDS (#4311, #4414, #4446, #4545): two full backends can share one root, the
+ * desktop app beside a `xum server` that started first (the desktop then skips only its API
+ * server) or any pair under XUM_ALLOW_MULTIPLE_INSTANCES=1. One rule governs them:
+ * A backend turns in-memory knowledge into a durable claim only about state it owns. It
+ * re-checks the claim's precondition against durable state under the lock that serializes the
+ * claim's write. It never judges a durable record whose liveness lives in another backend's
+ * memory; when it would need that knowledge, it leaves the record alone, answers
+ * "indeterminate", or refuses.
+ * Serialized across backends: config.json edits (editConfig CAS), history appends (the history
+ * write lock), and the crossProcessLock/fileLock kits (including the workspace-turn live-owner
+ * locks and refine-apply.lock). Per process: streams, Stop, owned attempts and settlement
+ * ledgers, send/rename/remove/busy guards, and delegated-turn reservations, so peer admission and
+ * instance discovery honor another backend's delegated turn only as a courtesy (the airtight
+ * version needs a cross-process admission registry, #4476).
+ * Driving one workspace or task from two backends at once is unsupported. Under that misuse two
+ * guarantees still hold for the attempt-fenced orchestration writes (report publication, settlement
+ * receipts, the plan-handoff boundary, task-state transitions): a superseded attempt's writes never
+ * land on its successor, and for a retired attempt a workflow accepts either the late report or the
+ * replacement, never both. Not fenced: the effects of an execution that is already running (its
+ * stream, tool calls, file changes and history rows continue until that backend stops it). Not
+ * guaranteed: a backend's startup recovery re-queues or re-drives `starting`/`running`/
+ * `awaiting_report` sub-agent tasks that a live backend still runs (a duplicate execution);
+ * UIs do not observe the other backend's writes until they resubscribe; mixed Xum versions on one
+ * root (#4480).
*/
import { spawnSync } from "node:child_process";