diff --git a/packages/server/src/server/agent-attention-policy.test.ts b/packages/server/src/server/agent-attention-policy.test.ts index fe3cae5ca..25c799e81 100644 --- a/packages/server/src/server/agent-attention-policy.test.ts +++ b/packages/server/src/server/agent-attention-policy.test.ts @@ -7,6 +7,7 @@ import { function state(overrides: Partial): ClientPresenceState { return { + appVisible: true, focusedAgentId: null, lastActivityAtMs: null, ...overrides, @@ -54,6 +55,23 @@ describe("computeNotificationPlan", () => { ).toEqual({ inAppRecipientIndex: null, shouldPush: false }); }); + it("does not suppress notifications when a focused client is backgrounded", () => { + const backgroundFocused = state({ + appVisible: false, + focusedAgentId: "agent-1", + lastActivityAtMs: presentAtMs, + }); + + expect( + computeNotificationPlan({ + allStates: [backgroundFocused], + agentId: "agent-1", + reason: "finished", + nowMs, + }), + ).toEqual({ inAppRecipientIndex: 0, shouldPush: false }); + }); + it("treats present clients focused on different agents as eligible", () => { expect( computeNotificationPlan({ diff --git a/packages/server/src/server/agent-attention-policy.ts b/packages/server/src/server/agent-attention-policy.ts index 8008b2735..353886844 100644 --- a/packages/server/src/server/agent-attention-policy.ts +++ b/packages/server/src/server/agent-attention-policy.ts @@ -3,6 +3,7 @@ import type { AgentAttentionReason } from "../shared/agent-attention-notificatio export const PRESENCE_THRESHOLD_MS = 180_000; export interface ClientPresenceState { + appVisible: boolean; lastActivityAtMs: number | null; focusedAgentId: string | null; } @@ -38,7 +39,7 @@ export function computeNotificationPlan({ continue; } - if (state.focusedAgentId === agentId) { + if (state.appVisible && state.focusedAgentId === agentId) { return { inAppRecipientIndex: null, shouldPush: false }; } diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 8b1751dd3..291199aaa 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -165,6 +165,7 @@ import { DownloadTokenStore } from "./file-download/token-store.js"; import { PushTokenStore } from "./push/token-store.js"; import { type WorktreeConfig } from "../utils/worktree.js"; import { runAsyncWorktreeBootstrap } from "./worktree-bootstrap.js"; +import { WorkspaceReconciliationService } from "./workspace-reconciliation-service.js"; import type { ScriptRouteStore } from "./script-proxy.js"; import { checkoutResolvedBranch, @@ -6440,7 +6441,43 @@ export class Session { } private async reconcileActiveWorkspaceRecords(): Promise> { - return new Set(); + const service = new WorkspaceReconciliationService({ + projectRegistry: this.projectRegistry, + workspaceRegistry: this.workspaceRegistry, + logger: this.sessionLogger, + workspaceGitService: this.workspaceGitService, + }); + const result = await service.runOnce(); + const changedWorkspaceIds = new Set(); + const changedProjectIds = new Set(); + + for (const change of result.changesApplied) { + switch (change.kind) { + case "workspace_archived": + await this.removeWorkspaceGitWatchTarget(change.directory); + this.scriptRuntimeStore?.removeForWorkspace(change.directory); + this.removeWorkspaceGitSubscription(change.workspaceId); + changedWorkspaceIds.add(change.workspaceId); + break; + case "workspace_updated": + changedWorkspaceIds.add(change.workspaceId); + break; + case "project_archived": + case "project_updated": + changedProjectIds.add(change.projectId); + break; + } + } + + if (changedProjectIds.size > 0) { + for (const workspace of await this.workspaceRegistry.list()) { + if (changedProjectIds.has(workspace.projectId)) { + changedWorkspaceIds.add(workspace.workspaceId); + } + } + } + + return changedWorkspaceIds; } private async emitWorkspaceUpdatesForWorkspaceIds( diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index 3a74e2d3e..e1a896c56 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -311,6 +311,59 @@ function createSessionForWorkspaceTests( } describe("workspace aggregation", () => { + test("workspace reconciliation reports archived workspaces to subscribed clients", async () => { + const missingCwd = path.join(tmpdir(), `paseo-missing-workspace-${Date.now()}`); + rmSync(missingCwd, { recursive: true, force: true }); + const projects = new Map([ + [ + "proj-missing", + createPersistedProjectRecord({ + projectId: "proj-missing", + rootPath: missingCwd, + kind: "non_git", + displayName: "missing", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }), + ], + ]); + const workspaces = new Map([ + [ + "ws-missing", + createPersistedWorkspaceRecord({ + workspaceId: "ws-missing", + projectId: "proj-missing", + cwd: missingCwd, + kind: "directory", + displayName: "missing", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }), + ], + ]); + const session = createSessionForWorkspaceTests() as any; + session.projectRegistry.list = async () => Array.from(projects.values()); + session.projectRegistry.archive = async (projectId: string, archivedAt: string) => { + const project = projects.get(projectId); + if (project) { + projects.set(projectId, { ...project, archivedAt }); + } + }; + session.workspaceRegistry.list = async () => Array.from(workspaces.values()); + session.workspaceRegistry.archive = async (workspaceId: string, archivedAt: string) => { + const workspace = workspaces.get(workspaceId); + if (workspace) { + workspaces.set(workspaceId, { ...workspace, archivedAt }); + } + }; + + const changedWorkspaceIds = await session.reconcileActiveWorkspaceRecords(); + + expect(changedWorkspaceIds).toEqual(new Set(["ws-missing"])); + expect(workspaces.get("ws-missing")?.archivedAt).toBeTruthy(); + expect(projects.get("proj-missing")?.archivedAt).toBeTruthy(); + }); + test("agent_update placement does not refresh git snapshots", async () => { const emitted: SessionOutboundMessage[] = []; const getSnapshot = vi.fn(async () => { diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index f2efd12fd..40bc232c3 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -1524,12 +1524,14 @@ export class VoiceAssistantWebSocketServer { const activity = session.getClientActivity(); if (!activity) { return { + appVisible: false, focusedAgentId: null, lastActivityAtMs: null, }; } return { + appVisible: activity.appVisible, focusedAgentId: activity.focusedAgentId, lastActivityAtMs: activity.lastActivityAt.getTime(), };