Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ extension SidebarBridge {
let report: @MainActor (ActionWorkFailure) -> Void = { [weak self] failure in
self?.services.registry.reportFailure(of: ActionID(rawValue: action.id), title: action.title, failure)
}
let home = services.machines.local
let home = services.machines.local, logger = home.logger
services.personalPlacements.enqueue(on: home, failed: settle) { [weak self] in
guard let connection = home.connection else {
report(ActionWorkFailure("set-personal-workspace", DaemonError.notConnected))
Expand All @@ -119,10 +119,16 @@ extension SidebarBridge {
failed: report, completed: { finish($0) }) { sending in
guard sending === connection else { throw DaemonError.notConnected }
for step in plan.steps {
try await sending.state.placePersonalWorkspace(session: step.workspace.session, key: step.workspace.key,
resource: step.workspace.resource,
group: step.moves ? (group.map { .set($0) } ?? .clear) : .unchanged,
index: step.index)
do {
try await sending.state.placePersonalWorkspace(session: step.workspace.session, key: step.workspace.key,
resource: step.workspace.resource,
group: step.moves ? (group.map { .set($0) } ?? .clear) : .unchanged,
index: step.index)
} catch let error as DaemonError where error.isWorkspaceNotFound {
// The workspace ended after the plan read it (an agent open under load whose half-made workspace
// closed): nothing is left to place, and no notice says a move failed (Leo, 2026-11-03).
logger.info("personal placement: workspace \(step.workspace.key.rawValue, privacy: .public) is gone; its step is dropped")
}
}
guard let first = plan.first else { return }
for id in regroup { try await sending.state.updateWorkspaceGroup(id.rawValue, topIndex: .set(first)) }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,4 +77,11 @@ extension DaemonError {
if case .connectionClosed(let reason) = self { return reason == LineTransport.busyRefusal }
return false
}

/// The daemon has no workspace by the id a request named (`selector.not_found` for a workspace): it ended, or its
/// create was undone, after the caller read it.
public var isWorkspaceNotFound: Bool {
guard case .command(_, _, let code?, let details, _) = self, code == "selector.not_found" else { return false }
return details?["scope"]?.stringValue == "workspace"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,11 @@ struct PlacementStaleRowsGalleryTests {
if mode == 'fail':
print(json.dumps({'id': request['id'], 'ok': False, 'error': 'placement refused'}), flush=True)
continue
if mode == 'gone' and request['workspace_key'] == 'k2':
# k2 closed before its placement ran: the daemon's answer to workspace.place for an id it has no more.
print(json.dumps({'id': request['id'], 'ok': False, 'error': {'code': 'selector.not_found', 'message': 'no workspace matches "ws_k2"',
'details': {'scope': 'workspace', 'selector': 'ws_k2'}}}), flush=True)
continue
key, index = request['workspace_key'], request['index']
order.remove(key)
order.insert(min(index, len(order)), key)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ struct PlacementWriteOrderTests {
private static func burst(mixed: Bool, mode: String = "") async throws {
_ = NSApplication.shared
let services = AppServices(environment: AppEnvironment.current([:]))
final class Notices { var shown: [String] = [] }
let notices = Notices()
services.registry.remoteFailureObserver = { _, notice in notices.shown.append(notice) }
let bridge = DaemonBridge.fakePython(["-c", PlacementStaleRowsGalleryTests.daemonScript, mixed ? "1" : "0", mode])
let connection = DaemonConnection(endpoint: .init(socketPath: "/tmp/cmux-placement-order-\(UUID().uuidString).sock", bridge: bridge),
configuration: .init(requiredCapabilities: [], terminalEnvironment: nil))
Expand Down Expand Up @@ -64,6 +67,13 @@ struct PlacementWriteOrderTests {
#expect(captured.order == ["k1", "k2", "k3", "k4"])
#expect(captured.writes.map(\.workspace_key) == ["k2", "k3", "k4"])
#expect(captured.writes.map(\.index) == [3, 3, 3])
} else if mode == "gone" {
// k2 ended before its placement ran (Leo, 2026-11-03: a "Move did not apply ... no workspace matches"
// notice after an agent open timed out under load): its step is dropped, nothing says a move failed,
// and the placements queued after it still apply.
#expect(scope.failures.isEmpty, "a gone workspace's placement failed the run: \(scope.failures)")
#expect(notices.shown.isEmpty, "a gone workspace's placement showed a notice: \(notices.shown)")
#expect(captured.writes.map(\.workspace_key) == ["k3", "k4"])
} else if mode == "closed" {
// Every placement skips its closed window; a skip is not a failure.
#expect(scope.failures.isEmpty)
Expand Down Expand Up @@ -92,6 +102,11 @@ struct PlacementWriteOrderTests {
try await Self.burst(mixed: false, mode: mode)
}

/// A placement whose workspace the daemon no longer has (`selector.not_found`) is dropped quietly, never toasted.
@Test func aPlacementForAGoneWorkspaceIsDroppedWithoutANotice() async throws {
try await Self.burst(mixed: false, mode: "gone")
}

/// A placement that skips (its window closed, its workspace deleted
/// before its turn) lets the ones queued after it run; a failed write
/// stops them. Each one that did not apply settles its pending edit once.
Expand Down
Loading