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";