From c18b6cd9bf4e0dc27a5d3aafd465ca9fbc0e1d56 Mon Sep 17 00:00:00 2001 From: Jono Minh <2witstudios@gmail.com> Date: Wed, 21 Oct 2026 23:46:19 -0500 Subject: [PATCH 1/7] fix(machines): keep a split made over SSH when the machine's app is open Splitting a pane in a project on another Mac and spawning into it made the agent a new sidebar row whenever PurePoint was also open on that Mac. Its app saved .pu/workspaces.json without checking for changes and gave the unknown agent its own workspace; the SSH client's conditional save then conflicted and took that layout, dropping the split. - Spawn requests carry an optional placement (workspace + tab). The daemon records it on the manifest entry and status report, and every app's reconciler puts the agent in that empty tab. - Local layout saves check the file's modification time and adopt a document another Mac wrote instead of overwriting it. The app also watches the file and re-checks it before reconciling. - A remote client writes its pending layout before sending the spawn. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013bBnogGfBnCLmktcEpVNJo --- .../Models/ManifestModel.swift | 4 +- .../purepoint-macos/Models/Workspace.swift | 35 +++++- .../Models/WorkspaceModel.swift | 9 +- .../Services/DaemonProtocol.swift | 12 +- .../Services/DaemonStatusSubscription.swift | 3 +- .../Services/DaemonWorkspaceService.swift | 3 +- .../purepoint-macos/State/ProjectState.swift | 48 +++++++- .../State/WorkspaceRegistry.swift | 66 ++++++++++- .../Views/PaneGrid/WorkspacePersistence.swift | 35 +++++- .../purepoint-macosTests/MachinesTests.swift | 2 +- .../WorkspaceReconcilerTests.swift | 107 ++++++++++++++++++ .../WorkspaceRegistryTests.swift | 58 ++++++++++ crates/pu-cli/src/client.rs | 1 + crates/pu-cli/src/commands/spawn.rs | 1 + crates/pu-cli/src/output/tests.rs | 1 + crates/pu-core/src/protocol/mod.rs | 3 + crates/pu-core/src/protocol/payloads.rs | 2 + crates/pu-core/src/protocol/tests.rs | 31 +++++ crates/pu-core/src/types/agent.rs | 54 +++++++++ crates/pu-core/src/types/manifest.rs | 3 + crates/pu-core/src/types/worktree.rs | 1 + .../pu-engine/src/engine/agent_lifecycle.rs | 1 + .../src/engine/definitions/swarm_defs.rs | 2 + crates/pu-engine/src/engine/inventory.rs | 1 + crates/pu-engine/src/engine/mod.rs | 64 +++++++++++ crates/pu-engine/src/engine/scheduler.rs | 2 + crates/pu-engine/src/engine/spawn.rs | 2 + crates/pu-engine/src/engine/status.rs | 1 + crates/pu-engine/src/test_helpers.rs | 1 + .../verification/owned-native-signals.rs | 1 + docs/reference/ipc-api.md | 5 +- 31 files changed, 534 insertions(+), 25 deletions(-) diff --git a/apps/purepoint-macos/purepoint-macos/Models/ManifestModel.swift b/apps/purepoint-macos/purepoint-macos/Models/ManifestModel.swift index 75091e60..8d559710 100644 --- a/apps/purepoint-macos/purepoint-macos/Models/ManifestModel.swift +++ b/apps/purepoint-macos/purepoint-macos/Models/ManifestModel.swift @@ -67,11 +67,12 @@ nonisolated struct AgentEntry: Codable, Sendable { let suspendedAt: String? let command: String? var signal: AgentSignalSnapshot? = nil + var placement: SpawnPlacement? = nil // Explicit CodingKeys document the camelCase wire format // matching Rust's #[serde(rename_all = "camelCase")] on types::AgentEntry. private enum CodingKeys: String, CodingKey { - case id, name, status, prompt, error, pid, suspended, command, signal + case id, name, status, prompt, error, pid, suspended, command, signal, placement case agentType, startedAt, completedAt, exitCode, sessionId, suspendedAt } } @@ -94,5 +95,6 @@ extension AgentEntry { suspendedAt = try c.decodeIfPresent(String.self, forKey: .suspendedAt) command = try c.decodeIfPresent(String.self, forKey: .command) signal = try? c.decodeIfPresent(AgentSignalSnapshot.self, forKey: .signal) + placement = try? c.decodeIfPresent(SpawnPlacement.self, forKey: .placement) } } diff --git a/apps/purepoint-macos/purepoint-macos/Models/Workspace.swift b/apps/purepoint-macos/purepoint-macos/Models/Workspace.swift index 2ed38e77..2534cc7e 100644 --- a/apps/purepoint-macos/purepoint-macos/Models/Workspace.swift +++ b/apps/purepoint-macos/purepoint-macos/Models/Workspace.swift @@ -432,10 +432,17 @@ nonisolated enum FocusDirection: Sendable { // MARK: - Reconciler +/// The tab an agent was spawned for, as recorded on its manifest entry. +nonisolated struct SpawnPlacement: Codable, Hashable, Sendable { + let workspace: String + let surface: Int +} + /// One live agent as the manifest reports it. nonisolated struct LiveAgent: Equatable, Sendable { let id: String let container: WorkspaceContainer + var placement: SpawnPlacement? = nil } /// The single function that turns (stored layout, manifest) into the canonical workspace list. @@ -451,8 +458,17 @@ nonisolated enum WorkspaceReconciler { /// 3. Every workspace holds a live agent or a file tab, or is waiting on a spawn /// (`reserved`) — no ghost rows. /// 4. Each workspace's container matches where its agents actually live. + /// 5. An agent no tab holds yet goes into the empty tab it was spawned for, when that tab + /// is still there — so every app showing the project places it the same way. static func reconcile(stored: [Workspace], live: [LiveAgent], reserved: Set = []) -> [Workspace] { let containerByAgent = Dictionary(live.map { ($0.id, $0.container) }, uniquingKeysWith: { first, _ in first }) + let storedAgentIds = Set(stored.flatMap(\.agentIds)) + var agentByPlacement: [SpawnPlacement: LiveAgent] = [:] + for agent in live where !storedAgentIds.contains(agent.id) { + guard let placement = agent.placement, agentByPlacement[placement] == nil else { continue } + agentByPlacement[placement] = agent + } + let placementWorkspaceIds = Set(agentByPlacement.keys.map(\.workspace)) var claimed = Set() var result: [Workspace] = [] @@ -485,6 +501,18 @@ nonisolated enum WorkspaceReconciler { } } + if placementWorkspaceIds.contains(workspace.id) { + for (_, surface) in workspace.surfaces where surface.content == .empty { + let placement = SpawnPlacement(workspace: workspace.id, surface: surface.id) + guard let agent = agentByPlacement[placement], !claimed.contains(agent.id), + workspace.agentIds.isEmpty || workspace.container == agent.container, + !workspace.isPointGuard || agent.container == .projectRoot + else { continue } + workspace.setContent(.agent(agent.id), forSurface: surface.id) + claimed.insert(agent.id) + } + } + // A workspace exists to hold what the user opened. One left with only empty tabs // is a ghost row — unless one of those tabs is waiting for its agent. guard !workspace.isGhost || reserved.contains(workspace.id) else { continue } @@ -510,9 +538,12 @@ nonisolated enum WorkspaceReconciler { /// Flatten a project's manifest into the ordered agent list the reconciler expects. /// Root agents first, then each worktree's agents — manifest order throughout. static func liveAgents(rootAgents: [AgentModel], worktrees: [WorktreeModel]) -> [LiveAgent] { - var live = rootAgents.map { LiveAgent(id: $0.id, container: .projectRoot) } + var live = rootAgents.map { LiveAgent(id: $0.id, container: .projectRoot, placement: $0.placement) } for worktree in worktrees { - live.append(contentsOf: worktree.agents.map { LiveAgent(id: $0.id, container: .worktree(worktree.id)) }) + live.append( + contentsOf: worktree.agents.map { + LiveAgent(id: $0.id, container: .worktree(worktree.id), placement: $0.placement) + }) } return live } diff --git a/apps/purepoint-macos/purepoint-macos/Models/WorkspaceModel.swift b/apps/purepoint-macos/purepoint-macos/Models/WorkspaceModel.swift index 1b2ac765..58eb35dc 100644 --- a/apps/purepoint-macos/purepoint-macos/Models/WorkspaceModel.swift +++ b/apps/purepoint-macos/purepoint-macos/Models/WorkspaceModel.swift @@ -24,6 +24,8 @@ nonisolated struct AgentModel: Identifiable, Equatable, Sendable { let suspended: Bool let command: String? var signal: AgentSignalSnapshot? = nil + /// The tab this agent was spawned for, if any. + var placement: SpawnPlacement? = nil var displayName: String { name.isEmpty ? id : name @@ -37,7 +39,8 @@ nonisolated struct AgentModel: Identifiable, Equatable, Sendable { init( id: String, name: String, agentType: String, status: AgentStatus, prompt: String, startedAt: String, - sessionId: String? = nil, suspended: Bool = false, command: String? = nil, signal: AgentSignalSnapshot? = nil + sessionId: String? = nil, suspended: Bool = false, command: String? = nil, signal: AgentSignalSnapshot? = nil, + placement: SpawnPlacement? = nil ) { self.id = id self.name = name @@ -49,6 +52,7 @@ nonisolated struct AgentModel: Identifiable, Equatable, Sendable { self.suspended = suspended self.command = command self.signal = signal + self.placement = placement } init(from entry: AgentEntry) { @@ -61,7 +65,8 @@ nonisolated struct AgentModel: Identifiable, Equatable, Sendable { startedAt: entry.startedAt, sessionId: entry.sessionId, suspended: entry.suspended ?? false, - command: entry.command + command: entry.command, + placement: entry.placement ) signal = entry.signal } diff --git a/apps/purepoint-macos/purepoint-macos/Services/DaemonProtocol.swift b/apps/purepoint-macos/purepoint-macos/Services/DaemonProtocol.swift index f2000c24..ea7065d9 100644 --- a/apps/purepoint-macos/purepoint-macos/Services/DaemonProtocol.swift +++ b/apps/purepoint-macos/purepoint-macos/Services/DaemonProtocol.swift @@ -308,7 +308,7 @@ nonisolated enum DaemonRequest: Encodable { case spawn( projectRoot: String, prompt: String, agent: String = "claude", name: String? = nil, base: String? = nil, root: Bool = false, - worktree: String? = nil, command: String? = nil) + worktree: String? = nil, command: String? = nil, placement: SpawnPlacement? = nil) case spawnShell(cwd: String) case kill(projectRoot: String, target: KillTarget) case rename(projectRoot: String, agentId: String, name: String) @@ -429,7 +429,10 @@ nonisolated enum DaemonRequest: Encodable { try container.encode(agentId, forKey: .key("agent_id")) try container.encode(cols, forKey: .key("cols")) try container.encode(rows, forKey: .key("rows")) - case .spawn(let projectRoot, let prompt, let agent, let name, let base, let root, let worktree, let command): + case .spawn( + let projectRoot, let prompt, let agent, let name, let base, let root, let worktree, let command, + let placement + ): try container.encode("spawn", forKey: .key("type")) try container.encode(projectRoot, forKey: .key("project_root")) try container.encode(prompt, forKey: .key("prompt")) @@ -439,6 +442,7 @@ nonisolated enum DaemonRequest: Encodable { if root { try container.encode(root, forKey: .key("root")) } if let worktree { try container.encode(worktree, forKey: .key("worktree")) } if let command { try container.encode(command, forKey: .key("command")) } + if let placement { try container.encode(placement, forKey: .key("placement")) } case .spawnShell(let cwd): try container.encode("spawn_shell", forKey: .key("type")) try container.encode(cwd, forKey: .key("cwd")) @@ -893,9 +897,10 @@ nonisolated struct AgentStatusReport: Decodable { let prompt: String? let suspended: Bool let signal: AgentSignalSnapshot? + let placement: SpawnPlacement? private enum CodingKeys: String, CodingKey { - case id, name, status, pid, prompt, suspended, signal + case id, name, status, pid, prompt, suspended, signal, placement case agentType = "agent_type" case exitCode = "exit_code" case idleSeconds = "idle_seconds" @@ -919,6 +924,7 @@ nonisolated struct AgentStatusReport: Decodable { prompt = try container.decodeIfPresent(String.self, forKey: .prompt) signal = try? container.decodeIfPresent(AgentSignalSnapshot.self, forKey: .signal) suspended = try container.decodeIfPresent(Bool.self, forKey: .suspended) ?? false + placement = try? container.decodeIfPresent(SpawnPlacement.self, forKey: .placement) } } diff --git a/apps/purepoint-macos/purepoint-macos/Services/DaemonStatusSubscription.swift b/apps/purepoint-macos/purepoint-macos/Services/DaemonStatusSubscription.swift index cf04ef58..50fe9c35 100644 --- a/apps/purepoint-macos/purepoint-macos/Services/DaemonStatusSubscription.swift +++ b/apps/purepoint-macos/purepoint-macos/Services/DaemonStatusSubscription.swift @@ -120,7 +120,8 @@ actor DaemonStatusSubscription { startedAt: report.startedAt ?? "", sessionId: report.sessionId, suspended: report.suspended, - signal: report.signal.flatMap { $0.identity.worktree_id == nil ? $0 : nil } + signal: report.signal.flatMap { $0.identity.worktree_id == nil ? $0 : nil }, + placement: report.placement ) } await onEvent(worktreeModels, agentModels) diff --git a/apps/purepoint-macos/purepoint-macos/Services/DaemonWorkspaceService.swift b/apps/purepoint-macos/purepoint-macos/Services/DaemonWorkspaceService.swift index 3ba5d989..11ffb4f5 100644 --- a/apps/purepoint-macos/purepoint-macos/Services/DaemonWorkspaceService.swift +++ b/apps/purepoint-macos/purepoint-macos/Services/DaemonWorkspaceService.swift @@ -25,7 +25,8 @@ nonisolated struct DaemonWorkspaceService: WorkspaceService { startedAt: report.startedAt ?? "", sessionId: report.sessionId, suspended: report.suspended, - signal: report.signal.flatMap { $0.identity.worktree_id == nil ? $0 : nil } + signal: report.signal.flatMap { $0.identity.worktree_id == nil ? $0 : nil }, + placement: report.placement ) } return WorkspaceSnapshot(worktrees: worktreeModels, rootAgents: rootAgents) diff --git a/apps/purepoint-macos/purepoint-macos/State/ProjectState.swift b/apps/purepoint-macos/purepoint-macos/State/ProjectState.swift index aa3623cf..bd49b8eb 100644 --- a/apps/purepoint-macos/purepoint-macos/State/ProjectState.swift +++ b/apps/purepoint-macos/purepoint-macos/State/ProjectState.swift @@ -51,6 +51,8 @@ final class ProjectState: Identifiable { @ObservationIgnored private let service: any WorkspaceService #if os(macOS) @ObservationIgnored private var manifestWatcher: ManifestWatcher? + /// Watches this project's `.pu/workspaces.json`, which a Mac working on it over SSH writes too. + @ObservationIgnored private var layoutWatcher: ManifestWatcher? #endif @ObservationIgnored private var gridSubscription: DaemonGridSubscription? @ObservationIgnored private var statusSubscription: DaemonStatusSubscription? @@ -134,6 +136,8 @@ final class ProjectState: Identifiable { refreshTask?.cancel() manifestWatcher?.stop() manifestWatcher = nil + layoutWatcher?.stop() + layoutWatcher = nil hasLoadedWorktrees = false openTask = Task { [weak self] in @@ -176,6 +180,12 @@ final class ProjectState: Identifiable { self.manifestWatcher = ManifestWatcher(path: manifestPath) { [weak self] in self?.refresh() } + let projectId = self.projectId + self.layoutWatcher = ManifestWatcher( + path: WorkspacePersistence.filePath(projectRoot: root) + ) { [weak self] in + self?.registry?.adoptLocalLayoutIfChanged(projectRoot: projectId) + } self.channel.startBackground() self.startGridSubscription() @@ -214,6 +224,8 @@ final class ProjectState: Identifiable { #if os(macOS) manifestWatcher?.stop() manifestWatcher = nil + layoutWatcher?.stop() + layoutWatcher = nil #endif hasLoadedWorktrees = false @@ -279,7 +291,7 @@ final class ProjectState: Identifiable { let weakSaver = WeakSaverRef(saver) saver.onConflict { document, modified in Task { @MainActor in - target.value?.adoptRemoteLayout( + target.value?.adoptExternalLayout( projectId, workspaces: WorkspacePersistence.workspaces(from: document, projectId: projectId)) weakSaver.value?.loaded(modifiedMs: modified) } @@ -317,6 +329,8 @@ final class ProjectState: Identifiable { #if os(macOS) manifestWatcher?.stop() manifestWatcher = nil + layoutWatcher?.stop() + layoutWatcher = nil #endif } @@ -348,6 +362,9 @@ final class ProjectState: Identifiable { func refresh() { let root = projectRoot let svc = service + #if os(macOS) + layoutWatcher?.retry() // the layout file may not have existed when watching began + #endif refreshTask?.cancel() refreshTask = Task { [weak self] in @@ -418,7 +435,8 @@ final class ProjectState: Identifiable { /// /// The tab is reserved before the request goes out, so whichever arrives first — the /// manifest write or the spawn response — the agent lands in this tab and never - /// surfaces as a workspace of its own. + /// surfaces as a workspace of its own. The request also names the tab, which the daemon + /// records on the agent, so any other Mac showing this project places it there too. func spawnAgentForSurface( agent: String, prompt: String, workspaceId: String, surfaceId: Int, name: String? = nil, command: String? = nil @@ -428,12 +446,30 @@ final class ProjectState: Identifiable { let spawnWorktree = workspace.container.worktreeId registry.reserveSurface(projectRoot: root, workspaceId: workspaceId, surfaceId: surfaceId) + let request = DaemonRequest.spawn( + projectRoot: projectRoot, prompt: prompt, agent: agent, + name: name, root: spawnWorktree == nil, worktree: spawnWorktree, command: command, + placement: SpawnPlacement(workspace: workspaceId, surface: surfaceId) + ) + + guard let saver = remoteLayoutSaver else { + sendSurfaceSpawn(request, workspaceId: workspaceId, surfaceId: surfaceId) + return + } + // The machine's app (if one has this project open) must see the new tab before the + // agent appears, or it has nowhere to put it: write the layout first. + registry.saveNow(projectRoot: root) + Task { + _ = await HostIO.value { saver.flush(until: .now() + 5) } + self.sendSurfaceSpawn(request, workspaceId: workspaceId, surfaceId: surfaceId) + } + } + private func sendSurfaceSpawn(_ request: DaemonRequest, workspaceId: String, surfaceId: Int) { + let root = projectId + let registry = self.registry sendDaemonRequest( - .spawn( - projectRoot: projectRoot, prompt: prompt, agent: agent, - name: name, root: spawnWorktree == nil, worktree: spawnWorktree, command: command - ), + request, onFailure: { [weak registry] in registry?.releaseReservation(projectRoot: root, workspaceId: workspaceId, surfaceId: surfaceId) } diff --git a/apps/purepoint-macos/purepoint-macos/State/WorkspaceRegistry.swift b/apps/purepoint-macos/purepoint-macos/State/WorkspaceRegistry.swift index 6974c082..6a396bb0 100644 --- a/apps/purepoint-macos/purepoint-macos/State/WorkspaceRegistry.swift +++ b/apps/purepoint-macos/purepoint-macos/State/WorkspaceRegistry.swift @@ -67,6 +67,10 @@ final class WorkspaceRegistry { @ObservationIgnored private let loadWorkspaces: (String) throws -> [Workspace] /// Remote projects persist through their machine's daemon rather than the local disk. @ObservationIgnored private var remoteSavers: [String: ([Workspace], String?) -> Void] = [:] + /// Modification time of each loaded local project's layout document as this app last read + /// or wrote it (nil: there was none). A Mac reaching this one over SSH writes the same + /// file, so a different time on disk is a change to adopt, never to overwrite. + @ObservationIgnored private var localLayoutStamps: [String: Int64?] = [:] init(loadWorkspaces: @escaping (String) throws -> [Workspace] = { try WorkspacePersistence.loadForRegistry(projectRoot: $0) }) { self.loadWorkspaces = loadWorkspaces @@ -92,9 +96,10 @@ final class WorkspaceRegistry { } } - /// The machine's layout document changed under this app (a conditional save hit a - /// conflict): adopt it instead of overwriting it, then fold in the live agents. - func adoptRemoteLayout(_ projectId: String, workspaces: [Workspace]) { + /// The project's layout document changed under this app (a conditional save hit a + /// conflict, or another Mac wrote this Mac's file): adopt it instead of overwriting it, + /// then fold in the live agents. + func adoptExternalLayout(_ projectId: String, workspaces: [Workspace]) { guard loadedProjects.contains(projectId) else { return } saveWorkItems[projectId]?.cancel() saveWorkItems[projectId] = nil @@ -113,6 +118,23 @@ final class WorkspaceRegistry { layoutErrorsByProject[projectId] = message.map { "Could not restore this project's layout: " + $0 } } + /// Adopt a local project's layout document if something other than this app wrote it + /// since this app last read or wrote it — a Mac that opened the project over SSH. Cheap + /// when nothing changed: one `stat`. + func adoptLocalLayoutIfChanged(projectRoot: String) { + guard !ProjectKey(id: projectRoot).isRemote, loadedProjects.contains(projectRoot), + let known = localLayoutStamps[projectRoot], + WorkspacePersistence.localModifiedMs(projectRoot: projectRoot) != known, + let read = try? LocalProjectHost.shared.read( + path: WorkspacePersistence.filePath(projectRoot: projectRoot), + maxBytes: WorkspacePersistence.maxRemoteLayoutBytes), + !read.truncated, !WorkspacePersistence.isNewerThanSupported(data: read.data) + else { return } + localLayoutStamps[projectRoot] = read.modifiedMs + adoptExternalLayout( + projectRoot, workspaces: WorkspacePersistence.workspaces(from: read.data, projectId: projectRoot)) + } + /// Names of file tabs in this project's workspaces holding unsaved edits. func unsavedFileNames(inProject projectRoot: String) -> [String] { (workspacesByProject[projectRoot] ?? []).flatMap { workspace in @@ -133,6 +155,7 @@ final class WorkspaceRegistry { let restored = try loadWorkspaces(projectRoot) workspacesByProject[projectRoot] = restored loadedProjects.insert(projectRoot) + localLayoutStamps[projectRoot] = WorkspacePersistence.localModifiedMs(projectRoot: projectRoot) layoutErrorsByProject[projectRoot] = nil return true } catch { @@ -214,6 +237,9 @@ final class WorkspaceRegistry { return } do { _ = try loadProjectIfNeeded(projectRoot) } catch { return } + // A Mac working on this project over SSH may have just written the tab this new agent + // was spawned for; take its layout first so the agent lands there. + adoptLocalLayoutIfChanged(projectRoot: projectRoot) publish(projectRoot: projectRoot, stored: workspacesByProject[projectRoot] ?? [], live: live) } @@ -560,13 +586,23 @@ final class WorkspaceRegistry { if let remoteSave { remoteSave(snapshot, active) } else { - WorkspacePersistence.save(snapshot, projectRoot: projectRoot, activeWorkspaceId: active) + MainActor.assumeIsolated { + self?.saveLocal(snapshot, projectRoot: projectRoot, activeWorkspaceId: active) + } } } saveWorkItems[projectRoot] = item DispatchQueue.main.asyncAfter(deadline: .now() + 1.0, execute: item) } + /// Write a project's pending layout change now rather than after the debounce. + func saveNow(projectRoot: String) { + guard pendingSaves.contains(projectRoot), let item = saveWorkItems[projectRoot] else { return } + saveWorkItems[projectRoot] = nil + item.perform() + item.cancel() + } + /// Flush pending writes — called on quit. func saveAll() { for (projectRoot, workspaces) in workspacesByProject { @@ -577,11 +613,30 @@ final class WorkspaceRegistry { // Unchanged since the last write: rewriting could revert another Mac's edit. if pending { remoteSave(workspaces, active) } } else if !ProjectKey(id: projectRoot).isRemote { - WorkspacePersistence.save(workspaces, projectRoot: projectRoot, activeWorkspaceId: active) + saveLocal(workspaces, projectRoot: projectRoot, activeWorkspaceId: active) } } } + /// Write a local project's layout, unless another Mac changed the file since this app + /// last read or wrote it — then adopt that layout instead of overwriting it. + private func saveLocal(_ workspaces: [Workspace], projectRoot: String, activeWorkspaceId: String?) { + guard let data = WorkspacePersistence.documentData(workspaces, activeWorkspaceId: activeWorkspaceId) else { + return + } + let expected = localLayoutStamps[projectRoot] ?? WorkspacePersistence.localModifiedMs(projectRoot: projectRoot) + switch WorkspacePersistence.saveLocal(data, projectRoot: projectRoot, expectedModifiedMs: expected) { + case .written(let modified): + localLayoutStamps[projectRoot] = modified + case .conflict(let document, let modified): + localLayoutStamps[projectRoot] = modified + adoptExternalLayout( + projectRoot, workspaces: WorkspacePersistence.workspaces(from: document, projectId: projectRoot)) + case .skipped: + break + } + } + private func activeWorkspaceId(inProject projectRoot: String) -> String? { guard let id = activeWorkspaceId, workspacesByProject[projectRoot]?.contains(where: { $0.id == id }) == true else { return nil } @@ -613,6 +668,7 @@ final class WorkspaceRegistry { layoutErrorsByProject[projectRoot] = nil loadedProjects.remove(projectRoot) remoteSavers[projectRoot] = nil + localLayoutStamps[projectRoot] = nil pruneActiveSelection() } } diff --git a/apps/purepoint-macos/purepoint-macos/Views/PaneGrid/WorkspacePersistence.swift b/apps/purepoint-macos/purepoint-macos/Views/PaneGrid/WorkspacePersistence.swift index 68b71c4c..99b93a4e 100644 --- a/apps/purepoint-macos/purepoint-macos/Views/PaneGrid/WorkspacePersistence.swift +++ b/apps/purepoint-macos/purepoint-macos/Views/PaneGrid/WorkspacePersistence.swift @@ -142,6 +142,39 @@ nonisolated enum WorkspacePersistence { try? data.write(to: url, options: .atomic) } + /// Write a local project's layout unless the document on disk changed since this app last + /// read or wrote it (`expectedModifiedMs`, nil when there was no document). Another Mac + /// reaching this one over SSH writes the same file; its change is returned as a conflict + /// for the caller to adopt instead of being overwritten. + static func saveLocal(_ data: Data, projectRoot: String, expectedModifiedMs: Int64?) -> RemoteSaveOutcome { + let host = LocalProjectHost.shared + let path = filePath(projectRoot: projectRoot) + guard let stat = try? host.stat(path: path) else { return .skipped } + let onDisk = stat.exists ? stat.modifiedMs : nil + if onDisk != expectedModifiedMs { + guard let current = try? host.read(path: path, maxBytes: maxRemoteLayoutBytes), !current.truncated, + !isNewerThanSupported(data: current.data) + else { return .skipped } + return .conflict(current.data, modifiedMs: current.modifiedMs) + } + guard !isNewerThanSupported(projectRoot: projectRoot) else { return .skipped } + do { + return .written( + modifiedMs: try host.write( + path: path, data: data, expectedModifiedMs: onDisk, createDirectories: true + ).modifiedMs) + } catch { + return .skipped + } + } + + /// Modification time of a local project's layout document, nil when there is none. + static func localModifiedMs(projectRoot: String) -> Int64? { + guard let stat = try? LocalProjectHost.shared.stat(path: filePath(projectRoot: projectRoot)), stat.exists + else { return nil } + return stat.modifiedMs + } + /// The encoded layout document. Shared by local saves and saves through a remote host. static func documentData(_ workspaces: [Workspace], activeWorkspaceId: String?) -> Data? { let document = PersistedWorkspaceDocument( @@ -441,7 +474,7 @@ nonisolated enum RemoteLayoutError: LocalizedError { } } -/// What a remote layout save did. +/// What a conditional layout save (remote, or local via `saveLocal`) did. nonisolated enum RemoteSaveOutcome: Equatable, Sendable { case written(modifiedMs: Int64) /// The machine's document changed since this app last read or wrote it; it was not diff --git a/apps/purepoint-macos/purepoint-macosTests/MachinesTests.swift b/apps/purepoint-macos/purepoint-macosTests/MachinesTests.swift index 6eb313c4..425a6256 100644 --- a/apps/purepoint-macos/purepoint-macosTests/MachinesTests.swift +++ b/apps/purepoint-macos/purepoint-macosTests/MachinesTests.swift @@ -803,7 +803,7 @@ struct RemoteLayoutConflictTests { changed.nextSurfaceId += 50 return changed } - registry.adoptRemoteLayout(projectId, workspaces: theirs) + registry.adoptExternalLayout(projectId, workspaces: theirs) #expect(registry.workspaces(forProject: projectId) == theirs) registry.forgetProject(projectId) } diff --git a/apps/purepoint-macos/purepoint-macosTests/WorkspaceReconcilerTests.swift b/apps/purepoint-macos/purepoint-macosTests/WorkspaceReconcilerTests.swift index 88f3d641..525949f9 100644 --- a/apps/purepoint-macos/purepoint-macosTests/WorkspaceReconcilerTests.swift +++ b/apps/purepoint-macos/purepoint-macosTests/WorkspaceReconcilerTests.swift @@ -314,4 +314,111 @@ struct WorkspaceReconcilerTests { #expect(WorkspacePersistence.load(projectRoot: root).isEmpty) } } + + // MARK: - Spawn placement + + /// ag-a with an empty tab split beside it, as a split made on any Mac leaves it. + private func makeSplitWaitingForAgent() -> (Workspace, Int) { + var workspace = Workspace.adopting(agentId: "ag-a", container: .projectRoot) + workspace.split(leafId: 0, axis: .vertical) + return (workspace, workspace.focusedSurface!.id) + } + + @Test func givenAgentSpawnedForAnEmptyTabShouldPlaceItThere() { + let (workspace, tab) = makeSplitWaitingForAgent() + let agents = [ + LiveAgent(id: "ag-a", container: .projectRoot), + LiveAgent( + id: "ag-new", container: .projectRoot, placement: SpawnPlacement(workspace: workspace.id, surface: tab)), + ] + + let result = WorkspaceReconciler.reconcile(stored: [workspace], live: agents) + + expectInvariant(result, agents) + #expect(result.map(\.id) == [workspace.id]) + #expect(result[0].surface(id: tab)?.content == .agent("ag-new")) + } + + @Test func givenPlacementTabAlreadyHoldsAnAgentShouldAdoptTheNewAgentSeparately() { + let (workspace, _) = makeSplitWaitingForAgent() + let agents = [ + LiveAgent(id: "ag-a", container: .projectRoot), + LiveAgent( + id: "ag-new", container: .projectRoot, placement: SpawnPlacement(workspace: workspace.id, surface: 0)), + ] + + let result = WorkspaceReconciler.reconcile(stored: [workspace], live: agents) + + expectInvariant(result, agents) + #expect(result.map(\.id) == [workspace.id, "ws-ag-new"]) + } + + @Test func givenPlacementWorkspaceIsUnknownShouldAdoptTheAgentSeparately() { + let (workspace, tab) = makeSplitWaitingForAgent() + let agents = [ + LiveAgent(id: "ag-a", container: .projectRoot), + LiveAgent( + id: "ag-new", container: .projectRoot, placement: SpawnPlacement(workspace: "ws-gone", surface: tab)), + ] + + let result = WorkspaceReconciler.reconcile(stored: [workspace], live: agents) + + expectInvariant(result, agents) + #expect(result.map(\.id) == [workspace.id, "ws-ag-new"]) + } + + @Test func givenPlacementInAnotherContainerShouldAdoptTheAgentSeparately() { + let (workspace, tab) = makeSplitWaitingForAgent() + let agents = [ + LiveAgent(id: "ag-a", container: .projectRoot), + LiveAgent( + id: "ag-new", container: .worktree("wt-1"), + placement: SpawnPlacement(workspace: workspace.id, surface: tab)), + ] + + let result = WorkspaceReconciler.reconcile(stored: [workspace], live: agents) + + expectInvariant(result, agents) + #expect(result.first { $0.agentIds == ["ag-new"] }?.container == .worktree("wt-1")) + } + + @Test func givenPlacedAgentShouldStayWhereTheLayoutHasItEvenIfItsPlacementDiffers() { + let (workspace, tab) = makeSplitWaitingForAgent() + let elsewhere = Workspace.adopting(agentId: "ag-new", container: .projectRoot) + let agents = [ + LiveAgent(id: "ag-a", container: .projectRoot), + LiveAgent( + id: "ag-new", container: .projectRoot, placement: SpawnPlacement(workspace: workspace.id, surface: tab)), + ] + + let result = WorkspaceReconciler.reconcile(stored: [workspace, elsewhere], live: agents) + + expectInvariant(result, agents) + #expect(result.first { $0.id == workspace.id }?.surface(id: tab)?.content == .empty) + #expect(result.first { $0.id == "ws-ag-new" }?.agentIds == ["ag-new"]) + } + + @Test func givenEmptyWorkspaceWaitingForItsAgentShouldBeKeptAndFilled() { + let waiting = Workspace( + id: "ws-new", container: .projectRoot, root: .leaf(id: 0), focusedLeafId: 0, nextLeafId: 1) + let tab = waiting.focusedSurface!.id + let agents = [ + LiveAgent( + id: "ag-new", container: .projectRoot, placement: SpawnPlacement(workspace: "ws-new", surface: tab)) + ] + + let result = WorkspaceReconciler.reconcile(stored: [waiting], live: agents) + + expectInvariant(result, agents) + #expect(result.map(\.id) == ["ws-new"]) + } + + @Test func givenAgentEntryWithPlacementShouldDecodeIt() throws { + let json = """ + {"id":"ag-1","name":"a","agentType":"claude","status":"running","startedAt":"2026-01-01T00:00:00Z", + "placement":{"workspace":"ws-ag-a","surface":2}} + """ + let entry = try JSONDecoder().decode(AgentEntry.self, from: Data(json.utf8)) + #expect(AgentModel(from: entry).placement == SpawnPlacement(workspace: "ws-ag-a", surface: 2)) + } } diff --git a/apps/purepoint-macos/purepoint-macosTests/WorkspaceRegistryTests.swift b/apps/purepoint-macos/purepoint-macosTests/WorkspaceRegistryTests.swift index f941df9f..aa533fac 100644 --- a/apps/purepoint-macos/purepoint-macosTests/WorkspaceRegistryTests.swift +++ b/apps/purepoint-macos/purepoint-macosTests/WorkspaceRegistryTests.swift @@ -196,6 +196,64 @@ struct WorkspaceRegistryTests { #expect(registry.workspace(id: "ws-ag-a")?.paneCount == 1) } + // MARK: - Layout written by another Mac + + /// Write `workspaces` as another Mac reaching this project over SSH would. + private func writeLayoutFromAnotherMac(_ workspaces: [Workspace], projectRoot: String) throws { + let path = WorkspacePersistence.filePath(projectRoot: projectRoot) + try FileManager.default.createDirectory( + atPath: (path as NSString).deletingLastPathComponent, withIntermediateDirectories: true) + let data = try #require(WorkspacePersistence.documentData(workspaces, activeWorkspaceId: nil)) + // A distinct modification time, as a write from another process would have. + Thread.sleep(forTimeInterval: 0.01) + try data.write(to: URL(fileURLWithPath: path), options: .atomic) + } + + @Test func givenAnotherMacSplitAPaneShouldPlaceItsNewAgentInThatPane() throws { + let project = root + "-split" + defer { try? FileManager.default.removeItem(atPath: project) } + let registry = makeRegistry(root: project, agents: ["ag-a"]) + var theirs = try #require(registry.workspace(id: "ws-ag-a")) + theirs.split(leafId: 0, axis: .vertical) + let tab = try #require(theirs.focusedSurface).id + try writeLayoutFromAnotherMac([theirs], projectRoot: project) + + var spawned = agent("ag-new") + spawned.placement = SpawnPlacement(workspace: "ws-ag-a", surface: tab) + registry.reconcile(projectRoot: project, rootAgents: [agent("ag-a"), spawned], worktrees: []) + + #expect(registry.workspaces(forProject: project).map(\.id) == ["ws-ag-a"]) + #expect(registry.workspace(id: "ws-ag-a")?.surface(id: tab)?.content == .agent("ag-new")) + } + + @Test func givenAnotherMacWroteTheLayoutASaveShouldAdoptItNotOverwriteIt() throws { + let project = root + "-save" + defer { try? FileManager.default.removeItem(atPath: project) } + let registry = makeRegistry(root: project, agents: ["ag-a"]) + var theirs = try #require(registry.workspace(id: "ws-ag-a")) + theirs.split(leafId: 0, axis: .vertical) + try writeLayoutFromAnotherMac([theirs], projectRoot: project) + let written = try Data(contentsOf: URL(fileURLWithPath: WorkspacePersistence.filePath(projectRoot: project))) + + registry.saveAll() + + #expect(registry.workspace(id: "ws-ag-a")?.paneCount == 2) + let onDisk = try Data(contentsOf: URL(fileURLWithPath: WorkspacePersistence.filePath(projectRoot: project))) + #expect(onDisk == written) + } + + @Test func givenOwnSavesShouldNotBeMistakenForAnotherMacs() throws { + let project = root + "-own" + defer { try? FileManager.default.removeItem(atPath: project) } + let registry = makeRegistry(root: project, agents: ["ag-a"]) + registry.saveAll() + registry.split(workspaceId: "ws-ag-a", leafId: 0, axis: .vertical) + + registry.adoptLocalLayoutIfChanged(projectRoot: project) + + #expect(registry.workspace(id: "ws-ag-a")?.paneCount == 2) + } + // MARK: - Remote commands @Test func remoteSplitAndCloseIgnoreUnknownLeaves() { diff --git a/crates/pu-cli/src/client.rs b/crates/pu-cli/src/client.rs index 4d2f36dd..a0bb3684 100644 --- a/crates/pu-cli/src/client.rs +++ b/crates/pu-cli/src/client.rs @@ -134,6 +134,7 @@ mod tests { plan_mode, no_trigger: false, trigger: None, + placement: None, }; assert_eq!(timeout_for(&spawn("review", false)), DELIVERY_TIMEOUT); assert_eq!(timeout_for(&spawn(" ", true)), DELIVERY_TIMEOUT); diff --git a/crates/pu-cli/src/commands/spawn.rs b/crates/pu-cli/src/commands/spawn.rs index 5c4954ed..b00575dc 100644 --- a/crates/pu-cli/src/commands/spawn.rs +++ b/crates/pu-cli/src/commands/spawn.rs @@ -87,6 +87,7 @@ pub async fn run( plan_mode, no_trigger, trigger, + placement: None, }, ) .await?; diff --git a/crates/pu-cli/src/output/tests.rs b/crates/pu-cli/src/output/tests.rs index 4b823a93..3fd9858c 100644 --- a/crates/pu-cli/src/output/tests.rs +++ b/crates/pu-cli/src/output/tests.rs @@ -19,6 +19,7 @@ fn make_agent_report(id: &str, status: AgentStatus) -> AgentStatusReport { trigger_seq_index: None, trigger_state: None, trigger_total: None, + placement: None, } } diff --git a/crates/pu-core/src/protocol/mod.rs b/crates/pu-core/src/protocol/mod.rs index 3d463147..b35277a2 100644 --- a/crates/pu-core/src/protocol/mod.rs +++ b/crates/pu-core/src/protocol/mod.rs @@ -120,6 +120,9 @@ pub enum Request { no_trigger: bool, #[serde(default)] trigger: Option, + /// The app tab this agent is for; recorded on its manifest entry. + #[serde(default, skip_serializing_if = "Option::is_none")] + placement: Option, }, Status { project_root: String, diff --git a/crates/pu-core/src/protocol/payloads.rs b/crates/pu-core/src/protocol/payloads.rs index 39b7cee5..99ba24f5 100644 --- a/crates/pu-core/src/protocol/payloads.rs +++ b/crates/pu-core/src/protocol/payloads.rs @@ -196,6 +196,8 @@ pub struct AgentStatusReport { pub trigger_state: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub trigger_total: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub placement: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/crates/pu-core/src/protocol/tests.rs b/crates/pu-core/src/protocol/tests.rs index 0e5c1af8..d6d696c2 100644 --- a/crates/pu-core/src/protocol/tests.rs +++ b/crates/pu-core/src/protocol/tests.rs @@ -39,6 +39,33 @@ fn given_spawn_request_should_default_agent_to_claude() { } } +#[test] +fn given_spawn_request_with_placement_should_parse() { + let json = r#"{"type":"spawn","project_root":"/test","prompt":"","placement":{"workspace":"ws-ag-1","surface":2}}"#; + let req: Request = serde_json::from_str(json).unwrap(); + match req { + Request::Spawn { placement, .. } => assert_eq!( + placement, + Some(crate::types::SpawnPlacement { + workspace: "ws-ag-1".into(), + surface: 2, + }) + ), + _ => panic!("expected Spawn"), + } +} + +#[test] +fn given_spawn_request_without_placement_should_omit_it() { + let json = r#"{"type":"spawn","project_root":"/test","prompt":""}"#; + let req: Request = serde_json::from_str(json).unwrap(); + let Request::Spawn { placement, .. } = &req else { + panic!("expected Spawn") + }; + assert!(placement.is_none()); + assert!(!serde_json::to_string(&req).unwrap().contains("placement")); +} + #[test] fn given_spawn_request_with_all_fields_should_round_trip() { let req = Request::Spawn { @@ -55,6 +82,7 @@ fn given_spawn_request_with_all_fields_should_round_trip() { plan_mode: false, no_trigger: false, trigger: None, + placement: None, }; let json = serde_json::to_string(&req).unwrap(); let parsed: Request = serde_json::from_str(&json).unwrap(); @@ -106,6 +134,7 @@ fn given_spawn_request_with_no_auto_true_should_round_trip() { plan_mode: false, no_trigger: false, trigger: None, + placement: None, }; let json = serde_json::to_string(&req).unwrap(); let parsed: Request = serde_json::from_str(&json).unwrap(); @@ -141,6 +170,7 @@ fn given_spawn_request_with_extra_args_should_round_trip() { plan_mode: false, no_trigger: false, trigger: None, + placement: None, }; let json = serde_json::to_string(&req).unwrap(); let parsed: Request = serde_json::from_str(&json).unwrap(); @@ -168,6 +198,7 @@ fn given_spawn_request_with_plan_mode_should_round_trip() { plan_mode: true, no_trigger: false, trigger: None, + placement: None, }; let json = serde_json::to_string(&req).unwrap(); let parsed: Request = serde_json::from_str(&json).unwrap(); diff --git a/crates/pu-core/src/types/agent.rs b/crates/pu-core/src/types/agent.rs index 078c5c96..b5b95d70 100644 --- a/crates/pu-core/src/types/agent.rs +++ b/crates/pu-core/src/types/agent.rs @@ -49,6 +49,15 @@ pub enum TriggerState { Failed, } +/// The app tab an agent was spawned for. The daemon only stores it: every app watching the +/// project places the agent in that tab instead of giving it a workspace of its own, so a +/// split made on one Mac stays a split on every other Mac showing the same project. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SpawnPlacement { + pub workspace: String, + pub surface: u32, +} + #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct AgentEntry { @@ -101,6 +110,8 @@ pub struct AgentEntry { /// Live observation snapshot; never use persisted observations without reconciliation. #[serde(default, skip_serializing_if = "Option::is_none")] pub signal: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub placement: Option, } impl<'de> Deserialize<'de> for AgentEntry { @@ -142,6 +153,8 @@ impl<'de> Deserialize<'de> for AgentEntry { trigger_name: Option, #[serde(default)] signal: Option, + #[serde(default)] + placement: Option, } let raw = Raw::deserialize(deserializer)?; // Backward compat: old manifests have suspended_at set but no suspended field. @@ -171,6 +184,7 @@ impl<'de> Deserialize<'de> for AgentEntry { bare_launch: raw.bare_launch, trigger_name: raw.trigger_name, signal: raw.signal, + placement: raw.placement, }) } } @@ -273,6 +287,7 @@ mod tests { trigger_name: None, signal: None, bare_launch: false, + placement: None, }; let json = serde_json::to_string(&entry).unwrap(); // Should use camelCase per manifest compat @@ -349,6 +364,7 @@ mod tests { trigger_name: None, signal: None, bare_launch: false, + placement: None, }; // when @@ -434,4 +450,42 @@ mod tests { assert_eq!(entry.gate_attempts, Some(1)); assert!(!entry.no_trigger); } + + #[test] + fn given_agent_entry_without_placement_should_deserialize_none() { + let json = r#"{ + "id": "ag-old", + "name": "claude", + "agentType": "claude", + "status": "running", + "prompt": null, + "startedAt": "2026-03-01T00:00:00Z" + }"#; + let entry: AgentEntry = serde_json::from_str(json).unwrap(); + assert!(entry.placement.is_none()); + let out = serde_json::to_value(&entry).unwrap(); + assert!(out.get("placement").is_none()); + } + + #[test] + fn given_agent_entry_with_placement_should_round_trip() { + let json = r#"{ + "id": "ag-new", + "name": "claude", + "agentType": "claude", + "status": "running", + "prompt": null, + "startedAt": "2026-03-01T00:00:00Z", + "placement": {"workspace": "ws-ag-1", "surface": 3} + }"#; + let entry: AgentEntry = serde_json::from_str(json).unwrap(); + let expected = SpawnPlacement { + workspace: "ws-ag-1".into(), + surface: 3, + }; + assert_eq!(entry.placement, Some(expected.clone())); + let reparsed: AgentEntry = + serde_json::from_str(&serde_json::to_string(&entry).unwrap()).unwrap(); + assert_eq!(reparsed.placement, Some(expected)); + } } diff --git a/crates/pu-core/src/types/manifest.rs b/crates/pu-core/src/types/manifest.rs index 6a332be2..ae143f7d 100644 --- a/crates/pu-core/src/types/manifest.rs +++ b/crates/pu-core/src/types/manifest.rs @@ -118,6 +118,7 @@ mod tests { trigger_name: None, signal: None, bare_launch: false, + placement: None, }, ); assert!(matches!(m.find_agent("ag-1"), Some(AgentLocation::Root(_)))); @@ -154,6 +155,7 @@ mod tests { trigger_name: None, signal: None, bare_launch: false, + placement: None, }, ); m.worktrees.insert( @@ -205,6 +207,7 @@ mod tests { trigger_name: None, signal: None, bare_launch: false, + placement: None, }; m.agents.insert("ag-root".into(), make_agent("ag-root")); let mut wt_agents = IndexMap::new(); diff --git a/crates/pu-core/src/types/worktree.rs b/crates/pu-core/src/types/worktree.rs index ec92d687..25ba4ebf 100644 --- a/crates/pu-core/src/types/worktree.rs +++ b/crates/pu-core/src/types/worktree.rs @@ -82,6 +82,7 @@ mod tests { trigger_name: None, signal: None, bare_launch: false, + placement: None, }, ); let entry = WorktreeEntry { diff --git a/crates/pu-engine/src/engine/agent_lifecycle.rs b/crates/pu-engine/src/engine/agent_lifecycle.rs index 119ccdd0..1e9cf82a 100644 --- a/crates/pu-engine/src/engine/agent_lifecycle.rs +++ b/crates/pu-engine/src/engine/agent_lifecycle.rs @@ -820,6 +820,7 @@ mod bare_launch_resume_tests { plan_mode: false, no_trigger: true, trigger: None, + placement: None, }) .await { diff --git a/crates/pu-engine/src/engine/definitions/swarm_defs.rs b/crates/pu-engine/src/engine/definitions/swarm_defs.rs index 7f949832..c0dcdf09 100644 --- a/crates/pu-engine/src/engine/definitions/swarm_defs.rs +++ b/crates/pu-engine/src/engine/definitions/swarm_defs.rs @@ -311,6 +311,7 @@ impl Engine { plan_mode: false, no_trigger: false, trigger: None, + placement: None, }) .await; @@ -357,6 +358,7 @@ impl Engine { plan_mode: false, no_trigger: false, trigger: None, + placement: None, }) .await; if let Response::SpawnResult { agent_id, .. } = resp { diff --git a/crates/pu-engine/src/engine/inventory.rs b/crates/pu-engine/src/engine/inventory.rs index 3bb2515f..6ef265f6 100644 --- a/crates/pu-engine/src/engine/inventory.rs +++ b/crates/pu-engine/src/engine/inventory.rs @@ -453,6 +453,7 @@ impl Engine { trigger_seq_index: None, trigger_state: None, trigger_total: None, + placement: None, })); } drop(sessions); diff --git a/crates/pu-engine/src/engine/mod.rs b/crates/pu-engine/src/engine/mod.rs index 1ac163df..a1ffe08c 100644 --- a/crates/pu-engine/src/engine/mod.rs +++ b/crates/pu-engine/src/engine/mod.rs @@ -57,6 +57,8 @@ pub(super) struct SpawnParams { no_trigger: bool, /// Name of trigger to bind (from --trigger flag) trigger: Option, + /// The app tab the agent is for, stored on its manifest entry. + placement: Option, } pub(super) struct SaveTriggerParams { @@ -488,6 +490,7 @@ impl Engine { plan_mode, no_trigger, trigger, + placement, } => { self.handle_spawn(SpawnParams { project_root, @@ -503,6 +506,7 @@ impl Engine { plan_mode, no_trigger, trigger, + placement, }) .await } @@ -1081,6 +1085,7 @@ mod tests { plan_mode: false, no_trigger: false, trigger: None, + placement: None, }) .await; @@ -1109,6 +1114,65 @@ mod tests { } } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn given_spawn_with_placement_should_record_it_on_manifest_and_status() { + let tmp = TempDir::new().unwrap(); + let project_root = tmp.path().join("project"); + std::fs::create_dir_all(&project_root).unwrap(); + let pr = project_root.to_string_lossy().to_string(); + let placement = pu_core::types::SpawnPlacement { + workspace: "ws-ag-1".into(), + surface: 4, + }; + + let engine = Engine::new(); + engine + .handle_request(Request::Init { + project_root: pr.clone(), + }) + .await; + let resp = engine + .handle_request(Request::Spawn { + project_root: pr.clone(), + prompt: String::new(), + agent: "terminal".into(), + name: None, + base: None, + root: true, + worktree: None, + command: None, + no_auto: false, + extra_args: vec![], + plan_mode: false, + no_trigger: true, + trigger: None, + placement: Some(placement.clone()), + }) + .await; + let agent_id = match resp { + Response::SpawnResult { agent_id, .. } => agent_id, + other => panic!("expected SpawnResult, got {other:?}"), + }; + + let m = pu_core::manifest::read_manifest(&project_root).unwrap(); + let Some(pu_core::types::AgentLocation::Root(agent)) = m.find_agent(&agent_id) else { + panic!("agent missing from manifest"); + }; + assert_eq!(agent.placement, Some(placement.clone())); + + let status = engine + .handle_request(Request::Status { + project_root: pr, + agent_id: Some(agent_id.clone()), + }) + .await; + match status { + Response::AgentStatus(report) => assert_eq!(report.placement, Some(placement)), + other => panic!("expected AgentStatus, got {other:?}"), + } + engine.kill_agents(&[agent_id]).await; + } + #[test] fn given_claude_prompt_should_inject_via_stdin() { assert!(Engine::should_inject_prompt_via_stdin( diff --git a/crates/pu-engine/src/engine/scheduler.rs b/crates/pu-engine/src/engine/scheduler.rs index 03b3a092..594472f4 100644 --- a/crates/pu-engine/src/engine/scheduler.rs +++ b/crates/pu-engine/src/engine/scheduler.rs @@ -93,6 +93,7 @@ impl Engine { plan_mode: false, no_trigger: false, trigger: None, + placement: None, }) .await } else { @@ -125,6 +126,7 @@ impl Engine { plan_mode: false, no_trigger: false, trigger: None, + placement: None, }) .await } diff --git a/crates/pu-engine/src/engine/spawn.rs b/crates/pu-engine/src/engine/spawn.rs index 8f0d3d7c..396d1ea6 100644 --- a/crates/pu-engine/src/engine/spawn.rs +++ b/crates/pu-engine/src/engine/spawn.rs @@ -80,6 +80,7 @@ impl Engine { plan_mode, no_trigger, trigger: trigger_param, + placement, } = params; let root_path = Path::new(&project_root); @@ -526,6 +527,7 @@ impl Engine { no_trigger, bare_launch: no_auto, trigger_name: trigger_name.clone(), + placement, }; let wt_id_for_manifest = worktree_id.clone(); diff --git a/crates/pu-engine/src/engine/status.rs b/crates/pu-engine/src/engine/status.rs index ccd2a91b..4063bd1b 100644 --- a/crates/pu-engine/src/engine/status.rs +++ b/crates/pu-engine/src/engine/status.rs @@ -89,6 +89,7 @@ impl Engine { trigger_seq_index: agent.trigger_seq_index, trigger_state: agent.trigger_state, trigger_total: agent.trigger_total, + placement: agent.placement.clone(), } } diff --git a/crates/pu-engine/src/test_helpers.rs b/crates/pu-engine/src/test_helpers.rs index e0dd5f6b..9e421972 100644 --- a/crates/pu-engine/src/test_helpers.rs +++ b/crates/pu-engine/src/test_helpers.rs @@ -41,6 +41,7 @@ pub(crate) async fn init_and_spawn_command(command: Option<&str>) -> (Engine, St plan_mode: false, no_trigger: false, trigger: None, + placement: None, }) .await; diff --git a/crates/pu-engine/verification/owned-native-signals.rs b/crates/pu-engine/verification/owned-native-signals.rs index e8f103a1..b3e0899d 100644 --- a/crates/pu-engine/verification/owned-native-signals.rs +++ b/crates/pu-engine/verification/owned-native-signals.rs @@ -486,6 +486,7 @@ fn main() { plan_mode: false, no_trigger: true, trigger: None, + placement: None, }) .await; save(dir, "spawn.json", &response)?; diff --git a/docs/reference/ipc-api.md b/docs/reference/ipc-api.md index 583b9cf2..10a8e9d1 100644 --- a/docs/reference/ipc-api.md +++ b/docs/reference/ipc-api.md @@ -208,11 +208,12 @@ Spawn a new agent, optionally in a new worktree. "extra_args": null, "plan_mode": false, "no_trigger": false, - "trigger": null + "trigger": null, + "placement": {"workspace": "ws-ag-abc", "surface": 2} } ``` -`no_auto: true` launches the agent's bare command, ignoring project and machine-level launch args; `extra_args` still apply. The `trigger` field optionally binds a trigger by name to the spawned agent. +`no_auto: true` launches the agent's bare command, ignoring project and machine-level launch args; `extra_args` still apply. The `trigger` field optionally binds a trigger by name to the spawned agent. `placement` (optional) names the app tab the agent was spawned for; the daemon only records it on the agent's manifest entry and status report, so every app showing the project puts the agent in that tab rather than giving it a workspace of its own. **Response**: ```json From 8e44490d30388a1be6583e56e86cc9dfff9d23d4 Mon Sep 17 00:00:00 2001 From: Jono Minh <2witstudios@gmail.com> Date: Wed, 21 Oct 2026 23:55:49 -0500 Subject: [PATCH 2/7] fix(macos): recreate a layout file deleted while its project is open A save expected the modification time it last saw, so once .pu/workspaces.json was deleted every later local or remote save conflicted, found nothing to adopt, and was skipped until the project was reopened. A missing document is now written again. Also stop the invalid-UTF-8 stream test failing on a broken pipe: the daemon may close the connection before the trailing request is written, which is the behaviour under test. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013bBnogGfBnCLmktcEpVNJo --- .../Views/PaneGrid/WorkspacePersistence.swift | 9 ++++++--- .../purepoint-macosTests/MachinesTests.swift | 10 ++++++++++ .../WorkspaceRegistryTests.swift | 15 +++++++++++++++ crates/pu-engine/src/ipc_server/tests.rs | 5 ++++- 4 files changed, 35 insertions(+), 4 deletions(-) diff --git a/apps/purepoint-macos/purepoint-macos/Views/PaneGrid/WorkspacePersistence.swift b/apps/purepoint-macos/purepoint-macos/Views/PaneGrid/WorkspacePersistence.swift index 99b93a4e..ceefd8f3 100644 --- a/apps/purepoint-macos/purepoint-macos/Views/PaneGrid/WorkspacePersistence.swift +++ b/apps/purepoint-macos/purepoint-macos/Views/PaneGrid/WorkspacePersistence.swift @@ -145,13 +145,14 @@ nonisolated enum WorkspacePersistence { /// Write a local project's layout unless the document on disk changed since this app last /// read or wrote it (`expectedModifiedMs`, nil when there was no document). Another Mac /// reaching this one over SSH writes the same file; its change is returned as a conflict - /// for the caller to adopt instead of being overwritten. + /// for the caller to adopt instead of being overwritten. A document deleted meanwhile has + /// nothing to adopt, so it is written again. static func saveLocal(_ data: Data, projectRoot: String, expectedModifiedMs: Int64?) -> RemoteSaveOutcome { let host = LocalProjectHost.shared let path = filePath(projectRoot: projectRoot) guard let stat = try? host.stat(path: path) else { return .skipped } let onDisk = stat.exists ? stat.modifiedMs : nil - if onDisk != expectedModifiedMs { + if let onDisk, onDisk != expectedModifiedMs { guard let current = try? host.read(path: path, maxBytes: maxRemoteLayoutBytes), !current.truncated, !isNewerThanSupported(data: current.data) else { return .skipped } @@ -260,8 +261,10 @@ nonisolated enum WorkspacePersistence { } do { return .written( + // `onDisk` equals `expectedModifiedMs` when both are known. A document deleted + // since it was read has nothing to adopt, so it is written again. modifiedMs: try host.write( - path: path, data: data, expectedModifiedMs: expectedModifiedMs ?? onDisk, createDirectories: true + path: path, data: data, expectedModifiedMs: onDisk, createDirectories: true ).modifiedMs) } catch HostError.conflict { // Changed between the read and the write: adopt what is there now. diff --git a/apps/purepoint-macos/purepoint-macosTests/MachinesTests.swift b/apps/purepoint-macos/purepoint-macosTests/MachinesTests.swift index 425a6256..7d5ffb79 100644 --- a/apps/purepoint-macos/purepoint-macosTests/MachinesTests.swift +++ b/apps/purepoint-macos/purepoint-macosTests/MachinesTests.swift @@ -609,6 +609,16 @@ struct RemoteLayoutPersistenceTests { #expect(outcome == .written(modifiedMs: 30)) } + @Test func givenDocumentDeletedSinceReadSaveShouldWriteItAgain() { + let host = FakeHost( + reads: [.failure(.notFound("gone"))], + writes: [.success(HostFileStat(exists: true, isDirectory: false, size: 1, modifiedMs: 30))]) + let outcome = WorkspacePersistence.saveRemote( + Data("{}".utf8), projectRoot: "/srv/app", host: host, expectedModifiedMs: 5) + #expect(host.writes.map(\.expectedModifiedMs) == [nil]) + #expect(outcome == .written(modifiedMs: 30)) + } + @Test func givenDocumentChangedElsewhereSaveShouldAdoptItNotOverwrite() { // Another Mac wrote the layout after this app last read it (5 → 20). let theirs = #"{"version":3,"workspaces":[]}"# diff --git a/apps/purepoint-macos/purepoint-macosTests/WorkspaceRegistryTests.swift b/apps/purepoint-macos/purepoint-macosTests/WorkspaceRegistryTests.swift index aa533fac..55f135db 100644 --- a/apps/purepoint-macos/purepoint-macosTests/WorkspaceRegistryTests.swift +++ b/apps/purepoint-macos/purepoint-macosTests/WorkspaceRegistryTests.swift @@ -242,6 +242,21 @@ struct WorkspaceRegistryTests { #expect(onDisk == written) } + @Test func givenLayoutFileDeletedASaveShouldWriteItAgain() throws { + let project = root + "-deleted" + defer { try? FileManager.default.removeItem(atPath: project) } + let registry = makeRegistry(root: project, agents: ["ag-a"]) + registry.saveAll() + let path = WorkspacePersistence.filePath(projectRoot: project) + try FileManager.default.removeItem(atPath: path) + registry.split(workspaceId: "ws-ag-a", leafId: 0, axis: .vertical) + + registry.saveAll() + + let restored = try WorkspacePersistence.loadForRegistry(projectRoot: project) + #expect(restored.first { $0.id == "ws-ag-a" }?.paneCount == 2) + } + @Test func givenOwnSavesShouldNotBeMistakenForAnotherMacs() throws { let project = root + "-own" defer { try? FileManager.default.removeItem(atPath: project) } diff --git a/crates/pu-engine/src/ipc_server/tests.rs b/crates/pu-engine/src/ipc_server/tests.rs index d4d46c74..ac6d428d 100644 --- a/crates/pu-engine/src/ipc_server/tests.rs +++ b/crates/pu-engine/src/ipc_server/tests.rs @@ -1396,7 +1396,10 @@ async fn assert_invalid_bytes_close_stream(sock_path: &std::path::Path, subscrib let first = read_response(&mut reader).await; assert!(subscribed(&first), "{first:?}"); writer.write_all(b"\xff{}\n").await.unwrap(); - write_line(&mut writer, &Request::Health).await; + // The daemon may already have closed the connection, so this write can fail with a + // broken pipe; either way the request must not run. + let health = serde_json::to_string(&Request::Health).unwrap() + "\n"; + let _ = writer.write_all(health.as_bytes()).await; let responses = responses_until_close(&mut reader).await; assert!( !responses From 5020293028789f54b6e6749914359badacc10c4f Mon Sep 17 00:00:00 2001 From: Jono Minh <2witstudios@gmail.com> Date: Thu, 22 Oct 2026 00:16:39 -0500 Subject: [PATCH 3/7] fix(macos): merge another Mac's layout instead of dropping local edits Independent review of the shared-layout fix found three ways a split could still be lost: - A save that conflicted adopted the other Mac's document wholesale, dropping this app's unsaved edits (the Mac running the project could now lose its own split to a write from an SSH client). Conflicts and external changes are now merged per workspace against the document last read or written (WorkspaceLayoutMerge): a workspace only this app changed keeps this app's version, anything the other Mac changed takes theirs, and the merged result is written back. - Point Guard workspace IDs embed each app's project key, so a placement made in Point Guard on another Mac never matched. Placements are re-keyed the way persistence re-keys the workspace. - A local project spawned before its layout was written; it now saves first too. The layout file's stamp is also taken before it is read. The isolated pane/registry verification package compiles again (it was already missing signal and transport types on main) and now also runs WorkspaceRegistryTests: 126 tests pass. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013bBnogGfBnCLmktcEpVNJo --- .../purepoint-macos/Models/Workspace.swift | 37 ++++++++++++ .../purepoint-macos/State/ProjectState.swift | 10 ++-- .../State/WorkspaceRegistry.swift | 42 ++++++++++++-- .../Views/PaneGrid/WorkspacePersistence.swift | 10 +++- .../WorkspaceReconcilerTests.swift | 50 +++++++++++++++++ .../WorkspaceRegistryTests.swift | 36 ++++++++++++ .../verification/PointGuardRoutingStubs.swift | 56 +++++++++++++++++-- .../check-point-guard-workspace.py | 19 +++++-- 8 files changed, 238 insertions(+), 22 deletions(-) diff --git a/apps/purepoint-macos/purepoint-macos/Models/Workspace.swift b/apps/purepoint-macos/purepoint-macos/Models/Workspace.swift index 2534cc7e..895488dd 100644 --- a/apps/purepoint-macos/purepoint-macos/Models/Workspace.swift +++ b/apps/purepoint-macos/purepoint-macos/Models/Workspace.swift @@ -443,6 +443,43 @@ nonisolated struct LiveAgent: Equatable, Sendable { let id: String let container: WorkspaceContainer var placement: SpawnPlacement? = nil + + /// A Point Guard workspace's ID embeds the project key of the app that made it, and every + /// app re-keys it to its own on load (see `WorkspacePersistence`). A placement another Mac + /// recorded names that Mac's ID, so it is re-keyed the same way. + func placementRekeyed(projectRoot: String) -> LiveAgent { + guard let placement, placement.workspace.hasPrefix("ws-point-guard:") else { return self } + var agent = self + agent.placement = SpawnPlacement( + workspace: Workspace.pointGuardId(projectRoot: projectRoot), surface: placement.surface) + return agent + } +} + +/// Folds a layout document another Mac wrote into this app's layout, one workspace at a time. +/// +/// Two apps write the same `.pu/workspaces.json` and every save is conditional, so a save that +/// finds the document changed has to take the other Mac's edits without dropping its own. +/// `base` is the document as this app last read or wrote it. A workspace only this app changed +/// or added keeps this app's version; one the other Mac changed, added or removed takes theirs, +/// including when both changed it — the document on disk is what both Macs share. +nonisolated enum WorkspaceLayoutMerge { + static func merge(base: [Workspace], ours: [Workspace], theirs: [Workspace]) -> [Workspace] { + let baseById = Dictionary(base.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first }) + let oursById = Dictionary(ours.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first }) + let theirIds = Set(theirs.map(\.id)) + var result: [Workspace] = [] + for their in theirs { + guard their == baseById[their.id] else { + result.append(their) + continue + } + // Untouched on their side: this app's version, unless this app removed it. + if let mine = oursById[their.id] { result.append(mine) } + } + result.append(contentsOf: ours.filter { !theirIds.contains($0.id) && baseById[$0.id] == nil }) + return result + } } /// The single function that turns (stored layout, manifest) into the canonical workspace list. diff --git a/apps/purepoint-macos/purepoint-macos/State/ProjectState.swift b/apps/purepoint-macos/purepoint-macos/State/ProjectState.swift index bd49b8eb..4b96f696 100644 --- a/apps/purepoint-macos/purepoint-macos/State/ProjectState.swift +++ b/apps/purepoint-macos/purepoint-macos/State/ProjectState.swift @@ -297,7 +297,9 @@ final class ProjectState: Identifiable { } } registry.registerRemoteProject(projectId, restored: layout.workspaces) { workspaces, active in - saver.save(workspaces, activeWorkspaceId: active) + saver.save(workspaces, activeWorkspaceId: active) { + Task { @MainActor in target.value?.layoutWritten(projectId, workspaces: workspaces) } + } } return } catch { @@ -452,13 +454,13 @@ final class ProjectState: Identifiable { placement: SpawnPlacement(workspace: workspaceId, surface: surfaceId) ) + // Another Mac showing this project must see the new tab before the agent appears, or + // it has nowhere to put it: write the layout first. + registry.saveNow(projectRoot: root) guard let saver = remoteLayoutSaver else { sendSurfaceSpawn(request, workspaceId: workspaceId, surfaceId: surfaceId) return } - // The machine's app (if one has this project open) must see the new tab before the - // agent appears, or it has nowhere to put it: write the layout first. - registry.saveNow(projectRoot: root) Task { _ = await HostIO.value { saver.flush(until: .now() + 5) } self.sendSurfaceSpawn(request, workspaceId: workspaceId, surfaceId: surfaceId) diff --git a/apps/purepoint-macos/purepoint-macos/State/WorkspaceRegistry.swift b/apps/purepoint-macos/purepoint-macos/State/WorkspaceRegistry.swift index 6a396bb0..18a19691 100644 --- a/apps/purepoint-macos/purepoint-macos/State/WorkspaceRegistry.swift +++ b/apps/purepoint-macos/purepoint-macos/State/WorkspaceRegistry.swift @@ -71,6 +71,9 @@ final class WorkspaceRegistry { /// or wrote it (nil: there was none). A Mac reaching this one over SSH writes the same /// file, so a different time on disk is a change to adopt, never to overwrite. @ObservationIgnored private var localLayoutStamps: [String: Int64?] = [:] + /// Each project's layout document as this app last read or wrote it, in its decoded form: + /// the base for merging a document another Mac wrote (`WorkspaceLayoutMerge`). + @ObservationIgnored private var layoutBases: [String: [Workspace]] = [:] init(loadWorkspaces: @escaping (String) throws -> [Workspace] = { try WorkspacePersistence.loadForRegistry(projectRoot: $0) }) { self.loadWorkspaces = loadWorkspaces @@ -91,24 +94,47 @@ final class WorkspaceRegistry { loadedProjects.insert(projectId) layoutErrorsByProject[projectId] = nil workspacesByProject[projectId] = restored + layoutBases[projectId] = restored if let live = liveByProject[projectId] { publish(projectRoot: projectId, stored: restored, live: live) } } /// The project's layout document changed under this app (a conditional save hit a - /// conflict, or another Mac wrote this Mac's file): adopt it instead of overwriting it, - /// then fold in the live agents. + /// conflict, or another Mac wrote this Mac's file): merge it with this app's layout instead + /// of overwriting it, fold in the live agents, and write back whatever this app adds. func adoptExternalLayout(_ projectId: String, workspaces: [Workspace]) { guard loadedProjects.contains(projectId) else { return } saveWorkItems[projectId]?.cancel() saveWorkItems[projectId] = nil pendingSaves.remove(projectId) - workspacesByProject[projectId] = workspaces + let merged = WorkspaceLayoutMerge.merge( + base: layoutBases[projectId] ?? [], + ours: decoded(workspacesByProject[projectId] ?? [], projectId: projectId), + theirs: workspaces) + layoutBases[projectId] = workspaces + workspacesByProject[projectId] = merged pruneActiveSelection() if let live = liveByProject[projectId] { - publish(projectRoot: projectId, stored: workspaces, live: live) + publish(projectRoot: projectId, stored: merged, live: live) } + if decoded(workspacesByProject[projectId] ?? [], projectId: projectId) != workspaces { + scheduleSave(projectRoot: projectId) + } + } + + /// A save of `workspaces` through the project's machine succeeded: it is now the base. + func layoutWritten(_ projectId: String, workspaces: [Workspace]) { + guard loadedProjects.contains(projectId) else { return } + layoutBases[projectId] = decoded(workspaces, projectId: projectId) + } + + /// `workspaces` as a document read back would give them, so they compare with one. + private func decoded(_ workspaces: [Workspace], projectId: String) -> [Workspace] { + guard let data = WorkspacePersistence.documentData(workspaces, activeWorkspaceId: nil) else { + return workspaces + } + return WorkspacePersistence.workspaces(from: data, projectId: projectId) } /// A remote layout read failed (anything but a missing file). Nothing is published or @@ -152,10 +178,13 @@ final class WorkspaceRegistry { guard !loadedProjects.contains(projectRoot) else { return false } guard !ProjectKey(id: projectRoot).isRemote else { throw RemoteLayoutPending() } do { + // Stamped before the read: a write landing in between then shows as a change. + let stamp = WorkspacePersistence.localModifiedMs(projectRoot: projectRoot) let restored = try loadWorkspaces(projectRoot) workspacesByProject[projectRoot] = restored loadedProjects.insert(projectRoot) - localLayoutStamps[projectRoot] = WorkspacePersistence.localModifiedMs(projectRoot: projectRoot) + localLayoutStamps[projectRoot] = stamp + layoutBases[projectRoot] = restored layoutErrorsByProject[projectRoot] = nil return true } catch { @@ -228,6 +257,7 @@ final class WorkspaceRegistry { /// read back, so every restart silently dissolved the grouping. func reconcile(projectRoot: String, rootAgents: [AgentModel], worktrees: [WorktreeModel]) { let live = WorkspaceReconciler.liveAgents(rootAgents: rootAgents, worktrees: worktrees) + .map { $0.placementRekeyed(projectRoot: projectRoot) } // A failed restore must still retain the current manifest for a later Retry. liveByProject[projectRoot] = live if isAwaitingRemoteLayout(projectRoot) { @@ -628,6 +658,7 @@ final class WorkspaceRegistry { switch WorkspacePersistence.saveLocal(data, projectRoot: projectRoot, expectedModifiedMs: expected) { case .written(let modified): localLayoutStamps[projectRoot] = modified + layoutBases[projectRoot] = decoded(workspaces, projectId: projectRoot) case .conflict(let document, let modified): localLayoutStamps[projectRoot] = modified adoptExternalLayout( @@ -669,6 +700,7 @@ final class WorkspaceRegistry { loadedProjects.remove(projectRoot) remoteSavers[projectRoot] = nil localLayoutStamps[projectRoot] = nil + layoutBases[projectRoot] = nil pruneActiveSelection() } } diff --git a/apps/purepoint-macos/purepoint-macos/Views/PaneGrid/WorkspacePersistence.swift b/apps/purepoint-macos/purepoint-macos/Views/PaneGrid/WorkspacePersistence.swift index ceefd8f3..1edc5747 100644 --- a/apps/purepoint-macos/purepoint-macos/Views/PaneGrid/WorkspacePersistence.swift +++ b/apps/purepoint-macos/purepoint-macos/Views/PaneGrid/WorkspacePersistence.swift @@ -524,18 +524,22 @@ nonisolated final class RemoteLayoutSaver: @unchecked Sendable { queue.async { self.knownModifiedMs = modifiedMs } } - func save(_ workspaces: [Workspace], activeWorkspaceId: String?) { + /// Queue a write of `workspaces`; `onWritten` runs (off the main thread) once it lands. + func save( + _ workspaces: [Workspace], activeWorkspaceId: String?, onWritten: (@Sendable () -> Void)? = nil + ) { guard let data = WorkspacePersistence.documentData(workspaces, activeWorkspaceId: activeWorkspaceId) else { return } - enqueue(data) + enqueue(data, onWritten: onWritten) } - func enqueue(_ data: Data) { + func enqueue(_ data: Data, onWritten: (@Sendable () -> Void)? = nil) { let group = self.group group.enter() queue.async { switch self.write(data, self.knownModifiedMs) { case .written(let modified): self.knownModifiedMs = modified + onWritten?() case .conflict(let document, let modified): // The stamp advances only once the document is adopted (`loaded`). self.conflictLock.lock() diff --git a/apps/purepoint-macos/purepoint-macosTests/WorkspaceReconcilerTests.swift b/apps/purepoint-macos/purepoint-macosTests/WorkspaceReconcilerTests.swift index 525949f9..95972465 100644 --- a/apps/purepoint-macos/purepoint-macosTests/WorkspaceReconcilerTests.swift +++ b/apps/purepoint-macos/purepoint-macosTests/WorkspaceReconcilerTests.swift @@ -421,4 +421,54 @@ struct WorkspaceReconcilerTests { let entry = try JSONDecoder().decode(AgentEntry.self, from: Data(json.utf8)) #expect(AgentModel(from: entry).placement == SpawnPlacement(workspace: "ws-ag-a", surface: 2)) } + + // MARK: - Layout merge + + @Test func givenEachMacChangedADifferentWorkspaceMergeShouldKeepBothChanges() { + let base = [ + Workspace.adopting(agentId: "ag-a", container: .projectRoot), + .adopting(agentId: "ag-b", container: .projectRoot), + ] + var ours = base + ours[0].split(leafId: 0, axis: .vertical) + var theirs = base + theirs[1].split(leafId: 0, axis: .horizontal) + + let merged = WorkspaceLayoutMerge.merge(base: base, ours: ours, theirs: theirs) + + #expect(merged == [ours[0], theirs[1]]) + } + + @Test func givenBothMacsChangedTheSameWorkspaceMergeShouldTakeTheirs() { + let base = [Workspace.adopting(agentId: "ag-a", container: .projectRoot)] + var ours = base + ours[0].split(leafId: 0, axis: .vertical) + var theirs = base + theirs[0].split(leafId: 0, axis: .horizontal) + + #expect(WorkspaceLayoutMerge.merge(base: base, ours: ours, theirs: theirs) == theirs) + } + + @Test func givenWorkspacesAddedAndRemovedMergeShouldApplyEachSidesChange() { + let a = Workspace.adopting(agentId: "ag-a", container: .projectRoot) + let b = Workspace.adopting(agentId: "ag-b", container: .projectRoot) + let mine = Workspace.adopting(agentId: "ag-mine", container: .projectRoot) + let theirsNew = Workspace.adopting(agentId: "ag-theirs", container: .projectRoot) + // We removed b and added mine; they removed a and added theirsNew. + let merged = WorkspaceLayoutMerge.merge(base: [a, b], ours: [a, mine], theirs: [b, theirsNew]) + + #expect(merged.map(\.id) == [theirsNew.id, mine.id]) + } + + @Test func givenPointGuardPlacementFromAnotherMacShouldBeRekeyedToThisApp() { + let agent = LiveAgent( + id: "ag-new", container: .projectRoot, + placement: SpawnPlacement(workspace: "ws-point-guard:machine:abc:/srv/app", surface: 3)) + + let rekeyed = agent.placementRekeyed(projectRoot: "/srv/app") + + #expect( + rekeyed.placement == SpawnPlacement(workspace: Workspace.pointGuardId(projectRoot: "/srv/app"), surface: 3)) + #expect(LiveAgent(id: "ag-x", container: .projectRoot).placementRekeyed(projectRoot: "/p").placement == nil) + } } diff --git a/apps/purepoint-macos/purepoint-macosTests/WorkspaceRegistryTests.swift b/apps/purepoint-macos/purepoint-macosTests/WorkspaceRegistryTests.swift index 55f135db..fb77a15e 100644 --- a/apps/purepoint-macos/purepoint-macosTests/WorkspaceRegistryTests.swift +++ b/apps/purepoint-macos/purepoint-macosTests/WorkspaceRegistryTests.swift @@ -257,6 +257,42 @@ struct WorkspaceRegistryTests { #expect(restored.first { $0.id == "ws-ag-a" }?.paneCount == 2) } + @Test func givenAnotherMacChangedADifferentWorkspaceASaveShouldKeepBothChanges() throws { + let project = root + "-merge" + defer { try? FileManager.default.removeItem(atPath: project) } + let registry = makeRegistry(root: project, agents: ["ag-a", "ag-b"]) + registry.saveAll() + var theirs = registry.workspaces(forProject: project) + let b = try #require(theirs.firstIndex { $0.id == "ws-ag-b" }) + theirs[b].split(leafId: 0, axis: .vertical) + try writeLayoutFromAnotherMac(theirs, projectRoot: project) + registry.split(workspaceId: "ws-ag-a", leafId: 0, axis: .vertical) + + registry.saveAll() // conflicts, merges + registry.saveAll() // writes the merged layout + + #expect(registry.workspace(id: "ws-ag-a")?.paneCount == 2) + #expect(registry.workspace(id: "ws-ag-b")?.paneCount == 2) + let onDisk = try WorkspacePersistence.loadForRegistry(projectRoot: project) + #expect(onDisk.map(\.paneCount) == [2, 2]) + } + + @Test func givenAgentSpawnedIntoPointGuardOnAnotherMacShouldLandInThisMacsPointGuard() throws { + let project = root + "-pg" + defer { try? FileManager.default.removeItem(atPath: project) } + let registry = makeRegistry(root: project, agents: []) + let pointGuard = try registry.ensurePointGuardWorkspace(projectRoot: project) + registry.split(workspaceId: pointGuard, leafId: 0, axis: .vertical) + let tab = try #require(registry.workspace(id: pointGuard)?.focusedSurface).id + + var spawned = agent("ag-new") + spawned.placement = SpawnPlacement(workspace: "ws-point-guard:machine:other:" + project, surface: tab) + registry.reconcile(projectRoot: project, rootAgents: [spawned], worktrees: []) + + #expect(registry.workspace(id: pointGuard)?.surface(id: tab)?.content == .agent("ag-new")) + #expect(registry.workspaceId(forAgent: "ag-new") == pointGuard) + } + @Test func givenOwnSavesShouldNotBeMistakenForAnotherMacs() throws { let project = root + "-own" defer { try? FileManager.default.removeItem(atPath: project) } diff --git a/apps/purepoint-macos/verification/PointGuardRoutingStubs.swift b/apps/purepoint-macos/verification/PointGuardRoutingStubs.swift index f31f2b3d..f729fc1e 100644 --- a/apps/purepoint-macos/verification/PointGuardRoutingStubs.swift +++ b/apps/purepoint-macos/verification/PointGuardRoutingStubs.swift @@ -9,6 +9,12 @@ import SwiftUI func start(chat: PiChatModel) {} } @MainActor final class AgentsHubState {} +@MainActor final class AgentSignalNotifications { + static let shared = AgentSignalNotifications() + var open: ((AgentSignalAlertPolicy.Agent) async -> Bool)? + func update(_ agents: [AgentSignalAlertPolicy.Agent], foregroundIdentity: String?) {} + func refreshBadge() {} +} @MainActor final class AgentConfigState {} @MainActor final class ScheduleState {} @MainActor final class TriggersState {} @@ -23,28 +29,60 @@ struct DaemonWorkspaceService: WorkspaceService { func manifestPath(projectRoot: String) -> String { "/dev/null" } } struct DaemonClient { - init(socketPath: String? = nil) {} + init(socketPath: String? = nil, transport: (any DaemonTransport)? = nil) {} func send(_ request: DaemonRequest) async throws -> DaemonResponse { .ok } } -// Project hosts: only the local, file-free surface AppState and persistence name. +protocol DaemonTransport: Sendable {} +struct UnixSocketTransport: DaemonTransport { let socketPath: String } +// Project hosts: only the local surface AppState and persistence name. The local host +// reads and writes the real disk, as layout saves through it must. struct HostFileData { let data: Data; let modifiedMs: Int64; let truncated: Bool } -struct HostFileStat { let modifiedMs: Int64 } +struct HostFileStat { + let exists: Bool + let modifiedMs: Int64 + static let missing = HostFileStat(exists: false, modifiedMs: 0) +} enum HostError: Error { case notFound(String), conflict(String) } protocol ProjectHost: Sendable { var isLocal: Bool { get } + func stat(path: String) throws -> HostFileStat func read(path: String, maxBytes: Int) throws -> HostFileData func write(path: String, data: Data, expectedModifiedMs: Int64?, createDirectories: Bool) throws -> HostFileStat } struct LocalProjectHost: ProjectHost { static let shared = LocalProjectHost() var isLocal: Bool { true } - func read(path: String, maxBytes: Int) throws -> HostFileData { throw CocoaError(.fileNoSuchFile) } + func stat(path: String) throws -> HostFileStat { + guard let attributes = try? FileManager.default.attributesOfItem(atPath: path) else { return .missing } + return HostFileStat(exists: true, modifiedMs: Self.milliseconds(attributes[.modificationDate] as? Date)) + } + func read(path: String, maxBytes: Int) throws -> HostFileData { + let data = try Data(contentsOf: URL(fileURLWithPath: path)) + return HostFileData( + data: data.prefix(maxBytes), modifiedMs: try stat(path: path).modifiedMs, truncated: data.count > maxBytes) + } func write(path: String, data: Data, expectedModifiedMs: Int64?, createDirectories: Bool) throws -> HostFileStat { - throw CocoaError(.fileWriteUnknown) + if let expectedModifiedMs, try stat(path: path).modifiedMs != expectedModifiedMs { + throw HostError.conflict(path) + } + let url = URL(fileURLWithPath: path) + if createDirectories { + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + } + try data.write(to: url, options: .atomic) + return try stat(path: path) } + static func milliseconds(_ date: Date?) -> Int64 { + Int64(((date?.timeIntervalSince1970 ?? 0) * 1000).rounded(.down)) + } +} +enum HostIO { + static func value(_ work: @escaping @Sendable () -> T) async -> T { work() } } struct UnavailableProjectHost: ProjectHost { var isLocal: Bool { false } + func stat(path: String) throws -> HostFileStat { .missing } func read(path: String, maxBytes: Int) throws -> HostFileData { throw HostError.notFound(path) } func write(path: String, data: Data, expectedModifiedMs: Int64?, createDirectories: Bool) throws -> HostFileStat { throw HostError.notFound(path) @@ -58,7 +96,7 @@ enum FileIOService { var projects: [ProjectState] = [] init(machine: Machine, appState: AppState?, registry: WorkspaceRegistry?) { self.machine = machine } func connect() {} - func disconnect(remember: Bool = false) {} + func disconnect(remember: Bool = false, keepUnsaved: Bool = false) {} func disconnectForTermination(deadline: DispatchTime) {} func update(from machine: Machine) {} func hideProject(root: String) {} @@ -83,7 +121,9 @@ enum DaemonLifecycle { let projectRoot: String var projectId: String { projectRoot } var isRemote: Bool { false } + var isUnavailable = false let host: any ProjectHost = LocalProjectHost.shared + let remoteLayoutSaver: RemoteLayoutSaver? = nil let client = DaemonClient() var rootAgents: [AgentModel] = [] var worktrees: [WorktreeModel] = [] @@ -98,6 +138,10 @@ enum DaemonLifecycle { func stopWatching() {} func agent(byId id: String) -> AgentModel? { allAgents.first { $0.id == id } } var allAgents: [AgentModel] { rootAgents + worktrees.flatMap(\.agents) } + func worktreeId(forAgentId agentId: String) -> String? { + worktrees.first { $0.agents.contains { $0.id == agentId } }?.id + } + func refresh() {} func sendDaemonRequest( _ request: DaemonRequest, onFailure: (() -> Void)? = nil, onSuccess: @escaping (DaemonResponse) -> Void diff --git a/apps/purepoint-macos/verification/check-point-guard-workspace.py b/apps/purepoint-macos/verification/check-point-guard-workspace.py index 9961559f..2e95b6fc 100644 --- a/apps/purepoint-macos/verification/check-point-guard-workspace.py +++ b/apps/purepoint-macos/verification/check-point-guard-workspace.py @@ -31,7 +31,8 @@ "State/AppState.swift", "Services/WorkspaceService.swift", "Services/DaemonProtocol.swift", "Views/PaneGrid/WorkspacePersistence.swift", "State/WorkspaceRegistry.swift", "Views/Chat/PointGuardView.swift", "Theme/Theme.swift", - "Models/ProjectKey.swift", "Models/Machine.swift" + "Models/ProjectKey.swift", "Models/Machine.swift", + "Models/AgentSignalSnapshot.swift", "Models/AgentSignalRouting.swift", "Models/AgentSignalPolicy.swift" ]: shutil.copyfile(source / name, sources / Path(name).name) @@ -49,8 +50,18 @@ (sources / "FileTabStoreStub.swift").write_text('''import Foundation @MainActor final class FileTabStore { struct Key: Hashable { let workspaceId: String; let surfaceId: Int } - func retain(only keys: Set) {} - func existingSession(workspaceId: String, surfaceId: Int) -> FileTabSession? { nil } + private var sessions: [Key: FileTabSession] = [:] + func retain(only keys: Set) { sessions = sessions.filter { keys.contains($0.key) } } + func existingSession(workspaceId: String, surfaceId: Int) -> FileTabSession? { + sessions[Key(workspaceId: workspaceId, surfaceId: surfaceId)] + } + func session(workspaceId: String, surfaceId: Int, initialPath: String?) -> FileTabSession { + let key = Key(workspaceId: workspaceId, surfaceId: surfaceId) + if let existing = sessions[key] { return existing } + let session = FileTabSession() + sessions[key] = session + return session + } } @MainActor final class FileTabSession { struct File { let name: String } @@ -61,7 +72,7 @@ ''') for name in [ "PointGuardWorkspaceTests.swift", "PointGuardRoutingTests.swift", "PaneTabStackTests.swift", - "WorkspaceReconcilerTests.swift", "WorkspaceModelTests.swift" + "WorkspaceReconcilerTests.swift", "WorkspaceModelTests.swift", "WorkspaceRegistryTests.swift" ]: shutil.copyfile(app / "purepoint-macosTests" / name, tests / name) # Include the production persistence suite that also runs in the app's CI target. From dbb78ca8ee86a514e8787623def28e2bd263abb8 Mon Sep 17 00:00:00 2001 From: Jono Minh <2witstudios@gmail.com> Date: Thu, 22 Oct 2026 00:31:26 -0500 Subject: [PATCH 4/7] fix(macos): keep a tab split for a spawn through a layout conflict From a second independent review of the layout merge: - When both Macs changed the same workspace, the other Mac's version won, which could drop the tab just split here for a spawn and bring back the original bug. The Mac that saves second now keeps its version. "Changed" is measured against the base as reconciled with the current agents, so groupings both Macs derive from the manifest never count as an edit. The other Mac then adopts the result unchanged, so the writes stop. - A save that conflicted only scheduled the merged write a second later, so a spawn (or quit) could go ahead before it landed. Local saves now write the merge immediately, and a remote spawn flushes again before sending. - A remote save made when no document was known overwrote one another Mac had created since. Any document this app has not seen is now a conflict. - A spawn waiting on the layout write is dropped if its tab was closed in the meantime. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013bBnogGfBnCLmktcEpVNJo --- .../purepoint-macos/Models/Workspace.swift | 21 +++++++++------- .../purepoint-macos/State/ProjectState.swift | 13 +++++++++- .../State/WorkspaceRegistry.swift | 25 ++++++++++++++++--- .../Views/PaneGrid/WorkspacePersistence.swift | 5 ++-- .../purepoint-macosTests/MachinesTests.swift | 9 +++++++ .../WorkspaceReconcilerTests.swift | 15 +++++++++-- .../WorkspaceRegistryTests.swift | 19 ++++++++++++++ 7 files changed, 89 insertions(+), 18 deletions(-) diff --git a/apps/purepoint-macos/purepoint-macos/Models/Workspace.swift b/apps/purepoint-macos/purepoint-macos/Models/Workspace.swift index 895488dd..c661cfd7 100644 --- a/apps/purepoint-macos/purepoint-macos/Models/Workspace.swift +++ b/apps/purepoint-macos/purepoint-macos/Models/Workspace.swift @@ -460,9 +460,11 @@ nonisolated struct LiveAgent: Equatable, Sendable { /// /// Two apps write the same `.pu/workspaces.json` and every save is conditional, so a save that /// finds the document changed has to take the other Mac's edits without dropping its own. -/// `base` is the document as this app last read or wrote it. A workspace only this app changed -/// or added keeps this app's version; one the other Mac changed, added or removed takes theirs, -/// including when both changed it — the document on disk is what both Macs share. +/// `base` is the document as this app last read or wrote it. A workspace this app changed or +/// added keeps this app's version — also when the other Mac changed it too, so a tab just split +/// here for a spawn survives — and everything else takes theirs. The other Mac then adopts the +/// result: its copy of each workspace is unchanged from its base, so it takes ours and no +/// further write follows. nonisolated enum WorkspaceLayoutMerge { static func merge(base: [Workspace], ours: [Workspace], theirs: [Workspace]) -> [Workspace] { let baseById = Dictionary(base.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first }) @@ -470,14 +472,15 @@ nonisolated enum WorkspaceLayoutMerge { let theirIds = Set(theirs.map(\.id)) var result: [Workspace] = [] for their in theirs { - guard their == baseById[their.id] else { - result.append(their) - continue + let original = baseById[their.id] + if let mine = oursById[their.id] { + result.append(mine != original ? mine : their) + } else if their != original { + result.append(their) // removed here, changed there: keep it } - // Untouched on their side: this app's version, unless this app removed it. - if let mine = oursById[their.id] { result.append(mine) } } - result.append(contentsOf: ours.filter { !theirIds.contains($0.id) && baseById[$0.id] == nil }) + // Added here, or changed here and removed there. + result.append(contentsOf: ours.filter { !theirIds.contains($0.id) && $0 != baseById[$0.id] }) return result } } diff --git a/apps/purepoint-macos/purepoint-macos/State/ProjectState.swift b/apps/purepoint-macos/purepoint-macos/State/ProjectState.swift index 4b96f696..ba9e889d 100644 --- a/apps/purepoint-macos/purepoint-macos/State/ProjectState.swift +++ b/apps/purepoint-macos/purepoint-macos/State/ProjectState.swift @@ -462,7 +462,18 @@ final class ProjectState: Identifiable { return } Task { - _ = await HostIO.value { saver.flush(until: .now() + 5) } + // A save that met another Mac's write merges and queues the merge; write that too. + for _ in 0..<2 { + _ = await HostIO.value { saver.flush(until: .now() + 5) } + await Task.yield() + guard registry.hasPendingSave(projectRoot: root) else { break } + registry.saveNow(projectRoot: root) + } + // The tab may have been closed, or the project closed, while the layout was written. + guard registry.workspace(id: workspaceId)?.surface(id: surfaceId)?.content == .empty else { + registry.releaseReservation(projectRoot: root, workspaceId: workspaceId, surfaceId: surfaceId) + return + } self.sendSurfaceSpawn(request, workspaceId: workspaceId, surfaceId: surfaceId) } } diff --git a/apps/purepoint-macos/purepoint-macos/State/WorkspaceRegistry.swift b/apps/purepoint-macos/purepoint-macos/State/WorkspaceRegistry.swift index 18a19691..723e42e2 100644 --- a/apps/purepoint-macos/purepoint-macos/State/WorkspaceRegistry.swift +++ b/apps/purepoint-macos/purepoint-macos/State/WorkspaceRegistry.swift @@ -108,10 +108,14 @@ final class WorkspaceRegistry { saveWorkItems[projectId]?.cancel() saveWorkItems[projectId] = nil pendingSaves.remove(projectId) + // The base as the reconciler would show it now: the grouping it derives from the + // manifest comes out the same on every Mac, so only what the user did here is "ours". + var base = layoutBases[projectId] ?? [] + if let live = liveByProject[projectId] { + base = decoded(WorkspaceReconciler.reconcile(stored: base, live: live), projectId: projectId) + } let merged = WorkspaceLayoutMerge.merge( - base: layoutBases[projectId] ?? [], - ours: decoded(workspacesByProject[projectId] ?? [], projectId: projectId), - theirs: workspaces) + base: base, ours: decoded(workspacesByProject[projectId] ?? [], projectId: projectId), theirs: workspaces) layoutBases[projectId] = workspaces workspacesByProject[projectId] = merged pruneActiveSelection() @@ -625,6 +629,9 @@ final class WorkspaceRegistry { DispatchQueue.main.asyncAfter(deadline: .now() + 1.0, execute: item) } + /// Whether a layout change of this project is waiting out the save debounce. + func hasPendingSave(projectRoot: String) -> Bool { pendingSaves.contains(projectRoot) } + /// Write a project's pending layout change now rather than after the debounce. func saveNow(projectRoot: String) { guard pendingSaves.contains(projectRoot), let item = saveWorkItems[projectRoot] else { return } @@ -650,7 +657,9 @@ final class WorkspaceRegistry { /// Write a local project's layout, unless another Mac changed the file since this app /// last read or wrote it — then adopt that layout instead of overwriting it. - private func saveLocal(_ workspaces: [Workspace], projectRoot: String, activeWorkspaceId: String?) { + private func saveLocal( + _ workspaces: [Workspace], projectRoot: String, activeWorkspaceId: String?, retryAfterConflict: Bool = true + ) { guard let data = WorkspacePersistence.documentData(workspaces, activeWorkspaceId: activeWorkspaceId) else { return } @@ -663,6 +672,14 @@ final class WorkspaceRegistry { localLayoutStamps[projectRoot] = modified adoptExternalLayout( projectRoot, workspaces: WorkspacePersistence.workspaces(from: document, projectId: projectRoot)) + // Write the merge now rather than after the debounce: the caller may be a spawn + // that needs its tab on disk, or quit. + guard retryAfterConflict, pendingSaves.remove(projectRoot) != nil else { return } + saveWorkItems[projectRoot]?.cancel() + saveWorkItems[projectRoot] = nil + saveLocal( + workspacesByProject[projectRoot] ?? [], projectRoot: projectRoot, + activeWorkspaceId: self.activeWorkspaceId(inProject: projectRoot), retryAfterConflict: false) case .skipped: break } diff --git a/apps/purepoint-macos/purepoint-macos/Views/PaneGrid/WorkspacePersistence.swift b/apps/purepoint-macos/purepoint-macos/Views/PaneGrid/WorkspacePersistence.swift index 1edc5747..65dd4c6b 100644 --- a/apps/purepoint-macos/purepoint-macos/Views/PaneGrid/WorkspacePersistence.swift +++ b/apps/purepoint-macos/purepoint-macos/Views/PaneGrid/WorkspacePersistence.swift @@ -236,7 +236,7 @@ nonisolated enum WorkspacePersistence { /// Save a remote project's layout through its host, never downgrading a newer document. /// The write is conditional on the modification time of the document this app last read - /// or wrote (`expectedModifiedMs`), else of the one just read. When the document changed + /// or wrote (`expectedModifiedMs`, nil when there was none). When the document changed /// meanwhile (another Mac, or `pu grid` on the machine) nothing is written: the outcome /// carries that document so the caller adopts it instead of reverting it. Nothing is /// written either when the current document cannot be read (anything but a missing file). @@ -250,7 +250,8 @@ nonisolated enum WorkspacePersistence { do { let existing = try host.read(path: path, maxBytes: maxRemoteLayoutBytes) if existing.truncated || isNewerThanSupported(data: existing.data) { return .skipped } - if let expectedModifiedMs, existing.modifiedMs != expectedModifiedMs { + // A document this app has not seen (none when it last read) is someone else's. + if existing.modifiedMs != expectedModifiedMs { return .conflict(existing.data, modifiedMs: existing.modifiedMs) } onDisk = existing.modifiedMs diff --git a/apps/purepoint-macos/purepoint-macosTests/MachinesTests.swift b/apps/purepoint-macos/purepoint-macosTests/MachinesTests.swift index 7d5ffb79..c3ac2054 100644 --- a/apps/purepoint-macos/purepoint-macosTests/MachinesTests.swift +++ b/apps/purepoint-macos/purepoint-macosTests/MachinesTests.swift @@ -609,6 +609,15 @@ struct RemoteLayoutPersistenceTests { #expect(outcome == .written(modifiedMs: 30)) } + @Test func givenDocumentCreatedSinceItWasReadAsMissingSaveShouldAdoptIt() { + let theirs = #"{"version":3,"workspaces":[]}"# + let host = FakeHost(reads: [file(theirs, modifiedMs: 20)]) + let outcome = WorkspacePersistence.saveRemote( + Data("{}".utf8), projectRoot: "/srv/app", host: host, expectedModifiedMs: nil) + #expect(outcome == .conflict(Data(theirs.utf8), modifiedMs: 20)) + #expect(host.writeCount == 0) + } + @Test func givenDocumentDeletedSinceReadSaveShouldWriteItAgain() { let host = FakeHost( reads: [.failure(.notFound("gone"))], diff --git a/apps/purepoint-macos/purepoint-macosTests/WorkspaceReconcilerTests.swift b/apps/purepoint-macos/purepoint-macosTests/WorkspaceReconcilerTests.swift index 95972465..9d9c62be 100644 --- a/apps/purepoint-macos/purepoint-macosTests/WorkspaceReconcilerTests.swift +++ b/apps/purepoint-macos/purepoint-macosTests/WorkspaceReconcilerTests.swift @@ -439,14 +439,16 @@ struct WorkspaceReconcilerTests { #expect(merged == [ours[0], theirs[1]]) } - @Test func givenBothMacsChangedTheSameWorkspaceMergeShouldTakeTheirs() { + @Test func givenBothMacsChangedTheSameWorkspaceMergeShouldKeepOurs() { let base = [Workspace.adopting(agentId: "ag-a", container: .projectRoot)] var ours = base ours[0].split(leafId: 0, axis: .vertical) var theirs = base theirs[0].split(leafId: 0, axis: .horizontal) - #expect(WorkspaceLayoutMerge.merge(base: base, ours: ours, theirs: theirs) == theirs) + // Ours keeps a tab split here for a spawn; the other Mac then adopts it unchanged. + #expect(WorkspaceLayoutMerge.merge(base: base, ours: ours, theirs: theirs) == ours) + #expect(WorkspaceLayoutMerge.merge(base: theirs, ours: theirs, theirs: ours) == ours) } @Test func givenWorkspacesAddedAndRemovedMergeShouldApplyEachSidesChange() { @@ -460,6 +462,15 @@ struct WorkspaceReconcilerTests { #expect(merged.map(\.id) == [theirsNew.id, mine.id]) } + @Test func givenWorkspaceChangedOnOneMacAndRemovedOnTheOtherMergeShouldKeepIt() { + let a = Workspace.adopting(agentId: "ag-a", container: .projectRoot) + var changed = a + changed.split(leafId: 0, axis: .vertical) + + #expect(WorkspaceLayoutMerge.merge(base: [a], ours: [changed], theirs: []) == [changed]) + #expect(WorkspaceLayoutMerge.merge(base: [a], ours: [], theirs: [changed]) == [changed]) + } + @Test func givenPointGuardPlacementFromAnotherMacShouldBeRekeyedToThisApp() { let agent = LiveAgent( id: "ag-new", container: .projectRoot, diff --git a/apps/purepoint-macos/purepoint-macosTests/WorkspaceRegistryTests.swift b/apps/purepoint-macos/purepoint-macosTests/WorkspaceRegistryTests.swift index fb77a15e..117c5dc5 100644 --- a/apps/purepoint-macos/purepoint-macosTests/WorkspaceRegistryTests.swift +++ b/apps/purepoint-macos/purepoint-macosTests/WorkspaceRegistryTests.swift @@ -277,6 +277,25 @@ struct WorkspaceRegistryTests { #expect(onDisk.map(\.paneCount) == [2, 2]) } + @Test func givenBothMacsChangedTheSameWorkspaceOneSaveShouldKeepThisMacsNewTabOnDisk() throws { + let project = root + "-same" + defer { try? FileManager.default.removeItem(atPath: project) } + let registry = makeRegistry(root: project, agents: ["ag-a"]) + registry.saveAll() + var theirs = registry.workspaces(forProject: project) + theirs[0].split(leafId: 0, axis: .horizontal) + try writeLayoutFromAnotherMac(theirs, projectRoot: project) + registry.split(workspaceId: "ws-ag-a", leafId: 0, axis: .vertical) + let tab = try #require(registry.workspace(id: "ws-ag-a")?.focusedSurface).id + + registry.saveNow(projectRoot: project) // as a spawn does: conflicts, merges, writes + + #expect(registry.workspace(id: "ws-ag-a")?.surface(id: tab)?.content == .empty) + let onDisk = try WorkspacePersistence.loadForRegistry(projectRoot: project) + #expect(onDisk.first?.surface(id: tab)?.content == .empty) + #expect(!registry.hasPendingSave(projectRoot: project)) + } + @Test func givenAgentSpawnedIntoPointGuardOnAnotherMacShouldLandInThisMacsPointGuard() throws { let project = root + "-pg" defer { try? FileManager.default.removeItem(atPath: project) } From efb4a2833aee82aba9da4a86cb95b8a220227aad Mon Sep 17 00:00:00 2001 From: Jono Minh <2witstudios@gmail.com> Date: Thu, 22 Oct 2026 00:43:59 -0500 Subject: [PATCH 5/7] fix(macos): don't let a lagging agent list undo another Mac's layout From a third independent review of the layout merge: - A document adopted from another Mac can place an agent this app's manifest does not list yet (that Mac saw the spawn first). The reconciler dropped such tabs as dead, collapsing the very split this branch keeps. Agents an adopted document names are now treated as live until the next manifest arrives; a current manifest that lacks them still removes their tabs. - A workspace closed here could come back: the other Mac's untouched copy was compared with the base as reconciled with this app's live agents, which had already lost the killed agent, so it looked changed there. Their changes are now measured against the base document itself; only ours are measured against the reconciled base. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013bBnogGfBnCLmktcEpVNJo --- .../purepoint-macos/Models/Workspace.swift | 24 ++++++++------- .../State/WorkspaceRegistry.swift | 29 +++++++++++++++---- .../WorkspaceReconcilerTests.swift | 13 +++++++++ .../WorkspaceRegistryTests.swift | 19 ++++++++++++ 4 files changed, 69 insertions(+), 16 deletions(-) diff --git a/apps/purepoint-macos/purepoint-macos/Models/Workspace.swift b/apps/purepoint-macos/purepoint-macos/Models/Workspace.swift index c661cfd7..a15b0c5b 100644 --- a/apps/purepoint-macos/purepoint-macos/Models/Workspace.swift +++ b/apps/purepoint-macos/purepoint-macos/Models/Workspace.swift @@ -460,27 +460,31 @@ nonisolated struct LiveAgent: Equatable, Sendable { /// /// Two apps write the same `.pu/workspaces.json` and every save is conditional, so a save that /// finds the document changed has to take the other Mac's edits without dropping its own. -/// `base` is the document as this app last read or wrote it. A workspace this app changed or -/// added keeps this app's version — also when the other Mac changed it too, so a tab just split -/// here for a spawn survives — and everything else takes theirs. The other Mac then adopts the -/// result: its copy of each workspace is unchanged from its base, so it takes ours and no -/// further write follows. +/// `base` is the document as this app last read or wrote it; their changes are measured against +/// it. Ours are measured against `oursBase` (by default the same), which a caller can set to the +/// base as its own reconciler shows it, so grouping derived from the manifest is not an edit. +/// A workspace this app changed or added keeps this app's version — also when the other Mac +/// changed it too, so a tab just split here for a spawn survives — and everything else takes +/// theirs. The other Mac then adopts the result: its copy of each workspace is unchanged from +/// its base, so it takes ours and no further write follows. nonisolated enum WorkspaceLayoutMerge { - static func merge(base: [Workspace], ours: [Workspace], theirs: [Workspace]) -> [Workspace] { + static func merge( + base: [Workspace], ours: [Workspace], theirs: [Workspace], oursBase: [Workspace]? = nil + ) -> [Workspace] { let baseById = Dictionary(base.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first }) + let oursBaseById = Dictionary((oursBase ?? base).map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first }) let oursById = Dictionary(ours.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first }) let theirIds = Set(theirs.map(\.id)) var result: [Workspace] = [] for their in theirs { - let original = baseById[their.id] if let mine = oursById[their.id] { - result.append(mine != original ? mine : their) - } else if their != original { + result.append(mine != oursBaseById[their.id] ? mine : their) + } else if their != baseById[their.id] { result.append(their) // removed here, changed there: keep it } } // Added here, or changed here and removed there. - result.append(contentsOf: ours.filter { !theirIds.contains($0.id) && $0 != baseById[$0.id] }) + result.append(contentsOf: ours.filter { !theirIds.contains($0.id) && $0 != oursBaseById[$0.id] }) return result } } diff --git a/apps/purepoint-macos/purepoint-macos/State/WorkspaceRegistry.swift b/apps/purepoint-macos/purepoint-macos/State/WorkspaceRegistry.swift index 723e42e2..8f9dc3ef 100644 --- a/apps/purepoint-macos/purepoint-macos/State/WorkspaceRegistry.swift +++ b/apps/purepoint-macos/purepoint-macos/State/WorkspaceRegistry.swift @@ -74,6 +74,10 @@ final class WorkspaceRegistry { /// Each project's layout document as this app last read or wrote it, in its decoded form: /// the base for merging a document another Mac wrote (`WorkspaceLayoutMerge`). @ObservationIgnored private var layoutBases: [String: [Workspace]] = [:] + /// Agents a document adopted from another Mac places in tabs before this app's manifest + /// lists them (that Mac saw the spawn first). They count as live until the next manifest, + /// so their tabs — and the split holding them — are not dropped as dead in the meantime. + @ObservationIgnored private var unconfirmedAgents: [String: [LiveAgent]] = [:] init(loadWorkspaces: @escaping (String) throws -> [Workspace] = { try WorkspacePersistence.loadForRegistry(projectRoot: $0) }) { self.loadWorkspaces = loadWorkspaces @@ -108,14 +112,22 @@ final class WorkspaceRegistry { saveWorkItems[projectId]?.cancel() saveWorkItems[projectId] = nil pendingSaves.remove(projectId) - // The base as the reconciler would show it now: the grouping it derives from the - // manifest comes out the same on every Mac, so only what the user did here is "ours". - var base = layoutBases[projectId] ?? [] + // Ours are measured against the base as the reconciler would show it now: the grouping it + // derives from the manifest comes out the same on every Mac, so it is not an edit here. + let base = layoutBases[projectId] ?? [] + let merged = WorkspaceLayoutMerge.merge( + base: base, ours: decoded(workspacesByProject[projectId] ?? [], projectId: projectId), theirs: workspaces, + oursBase: liveByProject[projectId].map { + decoded(WorkspaceReconciler.reconcile(stored: base, live: $0), projectId: projectId) + }) if let live = liveByProject[projectId] { - base = decoded(WorkspaceReconciler.reconcile(stored: base, live: live), projectId: projectId) + let known = Set(live.map(\.id)) + unconfirmedAgents[projectId] = workspaces.flatMap { workspace in + workspace.agentIds.filter { !known.contains($0) }.map { + LiveAgent(id: $0, container: workspace.container) + } + } } - let merged = WorkspaceLayoutMerge.merge( - base: base, ours: decoded(workspacesByProject[projectId] ?? [], projectId: projectId), theirs: workspaces) layoutBases[projectId] = workspaces workspacesByProject[projectId] = merged pruneActiveSelection() @@ -274,11 +286,15 @@ final class WorkspaceRegistry { // A Mac working on this project over SSH may have just written the tab this new agent // was spawned for; take its layout first so the agent lands there. adoptLocalLayoutIfChanged(projectRoot: projectRoot) + // This manifest is current: an agent a document names that it lacks has exited. + unconfirmedAgents[projectRoot] = nil publish(projectRoot: projectRoot, stored: workspacesByProject[projectRoot] ?? [], live: live) } /// Reconcile `stored` against `live` and publish the result if it changed. private func publish(projectRoot: String, stored: [Workspace], live: [LiveAgent]) { + let known = Set(live.map(\.id)) + let live = live + (unconfirmedAgents[projectRoot] ?? []).filter { !known.contains($0.id) } let reconciled = WorkspaceReconciler.reconcile( stored: stored, live: deferringReservedAgents(projectRoot: projectRoot, stored: stored, live: live), reserved: reservedWorkspaceIds(projectRoot: projectRoot)) @@ -718,6 +734,7 @@ final class WorkspaceRegistry { remoteSavers[projectRoot] = nil localLayoutStamps[projectRoot] = nil layoutBases[projectRoot] = nil + unconfirmedAgents[projectRoot] = nil pruneActiveSelection() } } diff --git a/apps/purepoint-macos/purepoint-macosTests/WorkspaceReconcilerTests.swift b/apps/purepoint-macos/purepoint-macosTests/WorkspaceReconcilerTests.swift index 9d9c62be..24bcafad 100644 --- a/apps/purepoint-macos/purepoint-macosTests/WorkspaceReconcilerTests.swift +++ b/apps/purepoint-macos/purepoint-macosTests/WorkspaceReconcilerTests.swift @@ -471,6 +471,19 @@ struct WorkspaceReconcilerTests { #expect(WorkspaceLayoutMerge.merge(base: [a], ours: [], theirs: [changed]) == [changed]) } + @Test func givenWorkspaceClosedHereAndUntouchedThereMergeShouldNotBringItBack() { + // Closed here: its agent is already gone from this app's live list, so the base as this + // app reconciles it holds only the file tab. The other Mac's copy is the base document. + var closed = Workspace.adopting(agentId: "ag-x", container: .projectRoot) + closed.newTab(leafId: 0, content: .file(path: "/p/README.md")) + var reconciledHere = closed + _ = reconciledHere.removeSurfaces { $0.content == .agent("ag-x") } + + let merged = WorkspaceLayoutMerge.merge(base: [closed], ours: [], theirs: [closed], oursBase: [reconciledHere]) + + #expect(merged.isEmpty) + } + @Test func givenPointGuardPlacementFromAnotherMacShouldBeRekeyedToThisApp() { let agent = LiveAgent( id: "ag-new", container: .projectRoot, diff --git a/apps/purepoint-macos/purepoint-macosTests/WorkspaceRegistryTests.swift b/apps/purepoint-macos/purepoint-macosTests/WorkspaceRegistryTests.swift index 117c5dc5..b14fdbd0 100644 --- a/apps/purepoint-macos/purepoint-macosTests/WorkspaceRegistryTests.swift +++ b/apps/purepoint-macos/purepoint-macosTests/WorkspaceRegistryTests.swift @@ -296,6 +296,25 @@ struct WorkspaceRegistryTests { #expect(!registry.hasPendingSave(projectRoot: project)) } + @Test func givenAnotherMacsLayoutNamesAnAgentThisMacHasNotSeenYetItsTabShouldWaitForTheManifest() throws { + let project = root + "-unseen" + defer { try? FileManager.default.removeItem(atPath: project) } + let registry = makeRegistry(root: project, agents: ["ag-a"]) + registry.saveAll() + var theirs = try #require(registry.workspace(id: "ws-ag-a")) + theirs.split(leafId: 0, axis: .vertical, content: .agent("ag-new")) + try writeLayoutFromAnotherMac([theirs], projectRoot: project) + + registry.adoptLocalLayoutIfChanged(projectRoot: project) // the file watcher, before the manifest + + #expect(registry.workspace(id: "ws-ag-a")?.agentIds == ["ag-a", "ag-new"]) + registry.reconcile(projectRoot: project, rootAgents: [agent("ag-a"), agent("ag-new")], worktrees: []) + #expect(registry.workspace(id: "ws-ag-a")?.agentIds == ["ag-a", "ag-new"]) + // A manifest without it means the agent exited: its tab goes. + registry.reconcile(projectRoot: project, rootAgents: [agent("ag-a")], worktrees: []) + #expect(registry.workspace(id: "ws-ag-a")?.agentIds == ["ag-a"]) + } + @Test func givenAgentSpawnedIntoPointGuardOnAnotherMacShouldLandInThisMacsPointGuard() throws { let project = root + "-pg" defer { try? FileManager.default.removeItem(atPath: project) } From de78f076c649923f3a5fd93a02e1f43c1f9dd250 Mon Sep 17 00:00:00 2001 From: Jono Minh <2witstudios@gmail.com> Date: Thu, 22 Oct 2026 00:55:45 -0500 Subject: [PATCH 6/7] fix(macos): count unconfirmed agents in the merge base From a fourth independent review: this app publishes its layout with the agents an adopted document placed before the manifest lists them, but the merge base was reconciled without them. A workspace holding such an agent then looked edited here, so a second split from the other Mac (for its next spawn) was overridden and written back, putting that spawn in its own row again. The base is now reconciled with the same agents. Unconfirmed agents are also taken from the merged layout rather than the other Mac's document, so an agent this app's version of a workspace does not hold is not given a stray row of its own. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013bBnogGfBnCLmktcEpVNJo --- .../State/WorkspaceRegistry.swift | 16 ++++++++++---- .../WorkspaceRegistryTests.swift | 21 +++++++++++++++++++ 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/apps/purepoint-macos/purepoint-macos/State/WorkspaceRegistry.swift b/apps/purepoint-macos/purepoint-macos/State/WorkspaceRegistry.swift index 8f9dc3ef..750de1ee 100644 --- a/apps/purepoint-macos/purepoint-macos/State/WorkspaceRegistry.swift +++ b/apps/purepoint-macos/purepoint-macos/State/WorkspaceRegistry.swift @@ -114,15 +114,18 @@ final class WorkspaceRegistry { pendingSaves.remove(projectId) // Ours are measured against the base as the reconciler would show it now: the grouping it // derives from the manifest comes out the same on every Mac, so it is not an edit here. + // `ours` was published with the unconfirmed agents, so the base is reconciled with them too. let base = layoutBases[projectId] ?? [] let merged = WorkspaceLayoutMerge.merge( base: base, ours: decoded(workspacesByProject[projectId] ?? [], projectId: projectId), theirs: workspaces, oursBase: liveByProject[projectId].map { - decoded(WorkspaceReconciler.reconcile(stored: base, live: $0), projectId: projectId) + decoded( + WorkspaceReconciler.reconcile(stored: base, live: withUnconfirmed($0, projectRoot: projectId)), + projectId: projectId) }) if let live = liveByProject[projectId] { let known = Set(live.map(\.id)) - unconfirmedAgents[projectId] = workspaces.flatMap { workspace in + unconfirmedAgents[projectId] = merged.flatMap { workspace in workspace.agentIds.filter { !known.contains($0) }.map { LiveAgent(id: $0, container: workspace.container) } @@ -291,10 +294,15 @@ final class WorkspaceRegistry { publish(projectRoot: projectRoot, stored: workspacesByProject[projectRoot] ?? [], live: live) } + /// `live` plus the agents an adopted document placed before the manifest listed them. + private func withUnconfirmed(_ live: [LiveAgent], projectRoot: String) -> [LiveAgent] { + let known = Set(live.map(\.id)) + return live + (unconfirmedAgents[projectRoot] ?? []).filter { !known.contains($0.id) } + } + /// Reconcile `stored` against `live` and publish the result if it changed. private func publish(projectRoot: String, stored: [Workspace], live: [LiveAgent]) { - let known = Set(live.map(\.id)) - let live = live + (unconfirmedAgents[projectRoot] ?? []).filter { !known.contains($0.id) } + let live = withUnconfirmed(live, projectRoot: projectRoot) let reconciled = WorkspaceReconciler.reconcile( stored: stored, live: deferringReservedAgents(projectRoot: projectRoot, stored: stored, live: live), reserved: reservedWorkspaceIds(projectRoot: projectRoot)) diff --git a/apps/purepoint-macos/purepoint-macosTests/WorkspaceRegistryTests.swift b/apps/purepoint-macos/purepoint-macosTests/WorkspaceRegistryTests.swift index b14fdbd0..928c5374 100644 --- a/apps/purepoint-macos/purepoint-macosTests/WorkspaceRegistryTests.swift +++ b/apps/purepoint-macos/purepoint-macosTests/WorkspaceRegistryTests.swift @@ -315,6 +315,27 @@ struct WorkspaceRegistryTests { #expect(registry.workspace(id: "ws-ag-a")?.agentIds == ["ag-a"]) } + @Test func givenAnotherMacSplitsAgainBeforeTheManifestCatchesUpItsNewTabShouldSurvive() throws { + let project = root + "-unseen-twice" + defer { try? FileManager.default.removeItem(atPath: project) } + let registry = makeRegistry(root: project, agents: ["ag-a"]) + registry.saveAll() + var theirs = try #require(registry.workspace(id: "ws-ag-a")) + theirs.split(leafId: 0, axis: .vertical, content: .agent("ag-z")) + try writeLayoutFromAnotherMac([theirs], projectRoot: project) + registry.adoptLocalLayoutIfChanged(projectRoot: project) + // Before this Mac's manifest lists ag-z, the other Mac splits again for its next spawn. + theirs.split(leafId: theirs.focusedLeafId, axis: .horizontal) + let tab = try #require(theirs.focusedSurface).id + try writeLayoutFromAnotherMac([theirs], projectRoot: project) + + registry.adoptLocalLayoutIfChanged(projectRoot: project) + + #expect(registry.workspace(id: "ws-ag-a")?.surface(id: tab)?.content == .empty) + #expect(registry.workspace(id: "ws-ag-a")?.agentIds == ["ag-a", "ag-z"]) + #expect(!registry.hasPendingSave(projectRoot: project)) + } + @Test func givenAgentSpawnedIntoPointGuardOnAnotherMacShouldLandInThisMacsPointGuard() throws { let project = root + "-pg" defer { try? FileManager.default.removeItem(atPath: project) } From f52bb4d04f254f0b6016e0bb90ece5a5ee9f73b1 Mon Sep 17 00:00:00 2001 From: Jono Minh <2witstudios@gmail.com> Date: Thu, 22 Oct 2026 01:18:08 -0500 Subject: [PATCH 7/7] refactor(macos): record a missing layout stamp with updateValue Assigning a nil Int64? through the subscript of an [String: Int64?] already stores a nil value (only a literal nil removes the key), but that reads as the removal a reviewer took it for. updateValue states the intent. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013bBnogGfBnCLmktcEpVNJo --- .../purepoint-macos/State/WorkspaceRegistry.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/purepoint-macos/purepoint-macos/State/WorkspaceRegistry.swift b/apps/purepoint-macos/purepoint-macos/State/WorkspaceRegistry.swift index 750de1ee..74226201 100644 --- a/apps/purepoint-macos/purepoint-macos/State/WorkspaceRegistry.swift +++ b/apps/purepoint-macos/purepoint-macos/State/WorkspaceRegistry.swift @@ -202,7 +202,8 @@ final class WorkspaceRegistry { let restored = try loadWorkspaces(projectRoot) workspacesByProject[projectRoot] = restored loadedProjects.insert(projectRoot) - localLayoutStamps[projectRoot] = stamp + // `updateValue` records a missing document as a nil stamp; `= nil` would drop the key. + localLayoutStamps.updateValue(stamp, forKey: projectRoot) layoutBases[projectRoot] = restored layoutErrorsByProject[projectRoot] = nil return true