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..a15b0c5b 100644 --- a/apps/purepoint-macos/purepoint-macos/Models/Workspace.swift +++ b/apps/purepoint-macos/purepoint-macos/Models/Workspace.swift @@ -432,10 +432,61 @@ 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 + + /// 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; 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], 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 { + if let mine = oursById[their.id] { + 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 != oursBaseById[$0.id] }) + return result + } } /// The single function that turns (stored layout, manifest) into the canonical workspace list. @@ -451,8 +502,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 +545,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 +582,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 0cf8e814..8afc6b7e 100644 --- a/apps/purepoint-macos/purepoint-macos/Services/DaemonProtocol.swift +++ b/apps/purepoint-macos/purepoint-macos/Services/DaemonProtocol.swift @@ -313,7 +313,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) @@ -440,7 +440,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")) @@ -450,6 +453,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")) @@ -915,9 +919,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" @@ -941,6 +946,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/AppState.swift b/apps/purepoint-macos/purepoint-macos/State/AppState.swift index 1d7a1c11..127fdc21 100644 --- a/apps/purepoint-macos/purepoint-macos/State/AppState.swift +++ b/apps/purepoint-macos/purepoint-macos/State/AppState.swift @@ -391,7 +391,7 @@ final class AppState { guard localProjectsSubscription == nil else { return } let subscription = DaemonProjectsSubscription(client: DaemonEndpoint.local.makeClient()) localProjectsSubscription = subscription - Task { + Task { [weak self] in // The daemon holds the project list, so it runs even before any project opens. try? await DaemonLifecycle.ensureDaemon() await subscription.start { [weak self] roots in self?.applyLocalProjects(roots) } diff --git a/apps/purepoint-macos/purepoint-macos/State/ProjectState.swift b/apps/purepoint-macos/purepoint-macos/State/ProjectState.swift index bd1a0279..8cedbe6f 100644 --- a/apps/purepoint-macos/purepoint-macos/State/ProjectState.swift +++ b/apps/purepoint-macos/purepoint-macos/State/ProjectState.swift @@ -54,6 +54,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? @@ -140,6 +142,8 @@ final class ProjectState: Identifiable { refreshTask?.cancel() manifestWatcher?.stop() manifestWatcher = nil + layoutWatcher?.stop() + layoutWatcher = nil hasLoadedWorktrees = false openTask = Task { [weak self] in @@ -182,6 +186,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() @@ -220,6 +230,8 @@ final class ProjectState: Identifiable { #if os(macOS) manifestWatcher?.stop() manifestWatcher = nil + layoutWatcher?.stop() + layoutWatcher = nil #endif hasLoadedWorktrees = false @@ -285,13 +297,15 @@ 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) } } 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 { @@ -323,6 +337,8 @@ final class ProjectState: Identifiable { #if os(macOS) manifestWatcher?.stop() manifestWatcher = nil + layoutWatcher?.stop() + layoutWatcher = nil #endif } @@ -354,6 +370,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 @@ -424,7 +443,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 @@ -434,12 +454,41 @@ 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) + ) + + // 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 + } + Task { + // 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) + } + } + 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..74226201 100644 --- a/apps/purepoint-macos/purepoint-macos/State/WorkspaceRegistry.swift +++ b/apps/purepoint-macos/purepoint-macos/State/WorkspaceRegistry.swift @@ -67,6 +67,17 @@ 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?] = [:] + /// 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 @@ -87,25 +98,64 @@ 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 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): 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 + // 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: withUnconfirmed($0, projectRoot: projectId)), + projectId: projectId) + }) + if let live = liveByProject[projectId] { + let known = Set(live.map(\.id)) + unconfirmedAgents[projectId] = merged.flatMap { workspace in + workspace.agentIds.filter { !known.contains($0) }.map { + LiveAgent(id: $0, container: workspace.container) + } + } + } + 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 /// saved for the project until a retry succeeds and `registerRemoteProject` runs. func setRemoteLayoutError(_ projectId: String, _ message: String?) { @@ -113,6 +163,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 @@ -130,9 +197,14 @@ 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) + // `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 } catch { @@ -205,6 +277,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) { @@ -214,11 +287,23 @@ 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) + // 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) } + /// `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 live = withUnconfirmed(live, projectRoot: projectRoot) let reconciled = WorkspaceReconciler.reconcile( stored: stored, live: deferringReservedAgents(projectRoot: projectRoot, stored: stored, live: live), reserved: reservedWorkspaceIds(projectRoot: projectRoot)) @@ -560,13 +645,26 @@ 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) } + /// 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 } + saveWorkItems[projectRoot] = nil + item.perform() + item.cancel() + } + /// Flush pending writes — called on quit. func saveAll() { for (projectRoot, workspaces) in workspacesByProject { @@ -577,11 +675,41 @@ 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?, retryAfterConflict: Bool = true + ) { + 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 + layoutBases[projectRoot] = decoded(workspaces, projectId: projectRoot) + case .conflict(let document, let modified): + 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 + } + } + private func activeWorkspaceId(inProject projectRoot: String) -> String? { guard let id = activeWorkspaceId, workspacesByProject[projectRoot]?.contains(where: { $0.id == id }) == true else { return nil } @@ -613,6 +741,9 @@ final class WorkspaceRegistry { layoutErrorsByProject[projectRoot] = nil loadedProjects.remove(projectRoot) remoteSavers[projectRoot] = nil + localLayoutStamps[projectRoot] = nil + layoutBases[projectRoot] = nil + unconfirmedAgents[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..65dd4c6b 100644 --- a/apps/purepoint-macos/purepoint-macos/Views/PaneGrid/WorkspacePersistence.swift +++ b/apps/purepoint-macos/purepoint-macos/Views/PaneGrid/WorkspacePersistence.swift @@ -142,6 +142,40 @@ 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. 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 let onDisk, 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( @@ -202,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). @@ -216,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 @@ -227,8 +262,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. @@ -441,7 +478,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 @@ -488,18 +525,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/MachinesTests.swift b/apps/purepoint-macos/purepoint-macosTests/MachinesTests.swift index dbbb03f6..cbdc393e 100644 --- a/apps/purepoint-macos/purepoint-macosTests/MachinesTests.swift +++ b/apps/purepoint-macos/purepoint-macosTests/MachinesTests.swift @@ -634,6 +634,25 @@ 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"))], + 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":[]}"# @@ -828,7 +847,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..24bcafad 100644 --- a/apps/purepoint-macos/purepoint-macosTests/WorkspaceReconcilerTests.swift +++ b/apps/purepoint-macos/purepoint-macosTests/WorkspaceReconcilerTests.swift @@ -314,4 +314,185 @@ 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)) + } + + // 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 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) + + // 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() { + 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 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 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, + 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 f941df9f..928c5374 100644 --- a/apps/purepoint-macos/purepoint-macosTests/WorkspaceRegistryTests.swift +++ b/apps/purepoint-macos/purepoint-macosTests/WorkspaceRegistryTests.swift @@ -196,6 +196,174 @@ 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 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 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 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 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 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) } + 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) } + 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/apps/purepoint-macos/verification/PointGuardRoutingStubs.swift b/apps/purepoint-macos/verification/PointGuardRoutingStubs.swift index d76f01b9..83c3fe44 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,29 +29,61 @@ 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 } func removeProject(root: String, suspendAgents: Bool, daemonName: String) async throws {} } -// 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) @@ -59,7 +97,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) {} @@ -77,7 +115,7 @@ struct MachineOpenProjectError: LocalizedError { } actor DaemonProjectsSubscription { init(client: DaemonClient) {} - func start(onChange: @escaping @MainActor ([String]) -> Void) async {} + func start(onChange: @escaping @MainActor @Sendable ([String]) -> Void) async {} func stop() {} } @MainActor final class ManifestWatcher { @@ -95,7 +133,9 @@ actor DaemonProjectsSubscription { var projectId: String { projectRoot } var key: ProjectKey { ProjectKey(id: 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] = [] @@ -111,6 +151,10 @@ actor DaemonProjectsSubscription { 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. 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 48e28f8b..f58d8205 100644 --- a/crates/pu-core/src/protocol/mod.rs +++ b/crates/pu-core/src/protocol/mod.rs @@ -131,6 +131,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 f3f0a9d2..2cd5902b 100644 --- a/crates/pu-core/src/protocol/tests.rs +++ b/crates/pu-core/src/protocol/tests.rs @@ -69,6 +69,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 { @@ -85,6 +112,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(); @@ -136,6 +164,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(); @@ -171,6 +200,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(); @@ -198,6 +228,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 e2c52720..8d1933e3 100644 --- a/crates/pu-engine/src/engine/inventory.rs +++ b/crates/pu-engine/src/engine/inventory.rs @@ -634,6 +634,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 884997cb..16e19c65 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 { @@ -502,6 +504,7 @@ impl Engine { plan_mode, no_trigger, trigger, + placement, } => { self.handle_spawn(SpawnParams { project_root, @@ -517,6 +520,7 @@ impl Engine { plan_mode, no_trigger, trigger, + placement, }) .await } @@ -1095,6 +1099,7 @@ mod tests { plan_mode: false, no_trigger: false, trigger: None, + placement: None, }) .await; @@ -1123,6 +1128,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 e5fe51d2..f8a4426c 100644 --- a/docs/reference/ipc-api.md +++ b/docs/reference/ipc-api.md @@ -219,11 +219,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