From ea4c6e887bc206a24ec692659816857fd45b5a1a Mon Sep 17 00:00:00 2001 From: Khaliq Date: Thu, 11 Jun 2026 14:14:14 +0200 Subject: [PATCH 1/6] fix(terminal): close the reconciler's silent-death modes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The quiet-time screen reconciler (the convergence backstop for renderer/ broker divergence) had three ways to be disabled silently, exactly while corruption was on screen: - A persistent PTY/grid dims mismatch — the very state that creates stacked-frame corruption — skipped every check with no log, forever. Now escalates after 2 consecutive quiet checks via onPersistentDimsMismatch: invalidate the size-sync ack, refit, and re-assert the rendered grid as the PTY size (30s rate limit). - A stranded optimistic-echo prediction held the quiet gate shut permanently: the engine has no time-based expiry, so a keystroke the TUI swallows (or typed into a hung agent) wedges hasPredictions true. Now rolled back after 10s of output silence; only optimistic glyphs are erased, confirmed bytes untouched. - A rejecting snapshot IPC was an unhandled rejection per 4s cycle; the check loop now catches and rate-limit-logs. Co-Authored-By: Claude Fable 5 --- AGENTS.md | 6 ++ .../src/lib/terminal-reconciler.test.ts | 73 +++++++++++++++++++ src/renderer/src/lib/terminal-reconciler.ts | 49 ++++++++++++- .../src/lib/terminal-runtime-registry.ts | 37 +++++++++- 4 files changed, 162 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 1db8bc4d..d04b75bb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,3 +16,9 @@ Pear broker work must treat duplicate delivery as a normal failure mode. Rendere ## Terminal Screen Convergence The broker daemon's PTY emulation (served as attach snapshots, observable via `agent-relay-broker dump-pty`) is the ground truth for what a worker terminal shows. The renderer's xterm grid can diverge from it (e.g. xterm reflow-scrolls on width resizes; PTY-side emulators don't), and diff-painting TUIs like Claude Code then *preserve* the divergence forever — they skip cells they believe unchanged, so stale glyphs bleed through new rows and stacked repaint frames accumulate. `src/renderer/src/lib/terminal-reconciler.ts` closes the loop: when a terminal is quiet it compares the viewport to the broker's `plain` snapshot and repaints from the self-framing `ansi` snapshot on confirmed divergence. Do not remove it after fixing any individual corruption vector — it is the convergence backstop for the whole class, and its gating invariants (quiet window, activity-serial recheck, dimension match, confirm-twice, rate limit) each guard a real re-corruption path documented in the module header. Repairs log `[terminal] viewport diverged from broker screen` — that line firing is the signal a new creation vector exists and is worth hunting. + +The gates themselves must not be able to disable the backstop silently or permanently: + +- **Persistent dims mismatch escalates, never just skips.** The dimension-match gate exists for resizes in flight, but a PTY that *stays* at a different size than the grid is the exact state that creates stacked-frame corruption (the TUI frames repaints for the wrong row count) — and it would also gate the reconciler off forever. After consecutive mismatched quiet checks the reconciler calls `onPersistentDimsMismatch`, which invalidates the size-sync ack and re-asserts the rendered grid as the PTY size (logs `[terminal] PTY RxC disagrees with grid RxC`). +- **Stranded predictions roll back instead of holding the quiet gate shut.** Optimistic-echo predictions are only confirmed or dropped by server output; a keystroke the TUI swallows (or one typed into a hung agent) leaves `hasPredictions` true with no echo ever coming, which would close the quiet gate permanently. After `STRANDED_PREDICTION_ROLLBACK_MS` of output silence the registry rolls the predictions back (erasing only optimistic glyphs — confirmed bytes are untouched) and lets reconciliation proceed. +- **A thrown check never kills the poll loop.** `snapshotTerminal` degrades to null, but the IPC bridge itself can reject; the check loop catches and rate-limit-logs instead of dying on an unhandled rejection. diff --git a/src/renderer/src/lib/terminal-reconciler.test.ts b/src/renderer/src/lib/terminal-reconciler.test.ts index 2b669d6d..03fa2f2d 100644 --- a/src/renderer/src/lib/terminal-reconciler.test.ts +++ b/src/renderer/src/lib/terminal-reconciler.test.ts @@ -3,6 +3,8 @@ import { createTerminalReconciler, screensMatch, RECONCILE_CONFIRM_CHECKS, + RECONCILE_DIMS_KICK_GAP_MS, + RECONCILE_ERROR_LOG_GAP_MS, RECONCILE_MIN_REPAIR_GAP_MS, type ReconcileSnapshot, type ReconcileViewport, @@ -160,6 +162,77 @@ describe('createTerminalReconciler', () => { reconciler.dispose() }) + it('escalates a persistent dims mismatch to onPersistentDimsMismatch, rate-limited', async () => { + const { deps, repairs, state } = makeHarness() + const kicks: Array<{ grid: { rows: number; cols: number }; snapshot: { rows: number; cols: number } }> = [] + deps.onPersistentDimsMismatch = (grid, snapshot) => kicks.push({ grid, snapshot }) + const reconciler = createTerminalReconciler(deps) + state.viewport = { rows: 3, cols: 10, lines: ['row one', 'row two', ''] } + await reconciler.checkNow() + // First sighting could be a resize still propagating — no kick yet. + expect(kicks).toEqual([]) + await reconciler.checkNow() + expect(kicks).toEqual([ + { grid: { rows: 3, cols: 10 }, snapshot: { rows: 2, cols: 10 } } + ]) + // Still mismatched inside the kick gap: no re-kick. + await reconciler.checkNow() + expect(kicks).toHaveLength(1) + state.now += RECONCILE_DIMS_KICK_GAP_MS + 1 + await reconciler.checkNow() + expect(kicks).toHaveLength(2) + // Dims mismatch never repaints. + expect(repairs).toEqual([]) + reconciler.dispose() + }) + + it('resets the dims-mismatch streak once dimensions agree again', async () => { + const { deps, state } = makeHarness() + const kicks: number[] = [] + deps.onPersistentDimsMismatch = () => kicks.push(1) + const reconciler = createTerminalReconciler(deps) + const matched = state.viewport! + state.viewport = { rows: 3, cols: 10, lines: ['row one', 'row two', ''] } + await reconciler.checkNow() + state.viewport = matched + await reconciler.checkNow() + state.viewport = { rows: 3, cols: 10, lines: ['row one', 'row two', ''] } + await reconciler.checkNow() + expect(kicks).toEqual([]) + reconciler.dispose() + }) + + it('survives a throwing snapshot fetch and keeps checking (rate-limited log)', async () => { + const { deps, repairs, state } = makeHarness() + const logs: string[] = [] + deps.log = (message) => logs.push(message) + let failNext = true + const baseFetch = deps.fetchSnapshot + deps.fetchSnapshot = async (format) => { + if (failNext) { + failNext = false + throw new Error('ipc bridge dead') + } + return baseFetch(format) + } + const reconciler = createTerminalReconciler(deps) + diverge(state) + await reconciler.checkNow() // throws internally; must not propagate + expect(logs.some((m) => m.includes('reconcile check failed'))).toBe(true) + await confirmCycles(reconciler) + expect(repairs).toHaveLength(1) + // Error log is rate-limited: a second failure inside the gap is silent. + const logCount = logs.filter((m) => m.includes('reconcile check failed')).length + failNext = true + await reconciler.checkNow() + expect(logs.filter((m) => m.includes('reconcile check failed'))).toHaveLength(logCount) + state.now += RECONCILE_ERROR_LOG_GAP_MS + 1 + failNext = true + await reconciler.checkNow() + expect(logs.filter((m) => m.includes('reconcile check failed'))).toHaveLength(logCount + 1) + reconciler.dispose() + }) + it('rate-limits repairs', async () => { const { deps, repairs, state } = makeHarness() const reconciler = createTerminalReconciler(deps) diff --git a/src/renderer/src/lib/terminal-reconciler.ts b/src/renderer/src/lib/terminal-reconciler.ts index b1828943..ce621f45 100644 --- a/src/renderer/src/lib/terminal-reconciler.ts +++ b/src/renderer/src/lib/terminal-reconciler.ts @@ -47,6 +47,12 @@ export const RECONCILE_QUIET_MS = 1_500 export const RECONCILE_MIN_REPAIR_GAP_MS = 15_000 // Confirmations required on consecutive checks before repairing. export const RECONCILE_CONFIRM_CHECKS = 2 +// Consecutive quiet checks with snapshot dims ≠ grid dims before the +// mismatch counts as persistent rather than a resize in flight (one check +// interval is ample time for the size-sync loop to settle a real resize). +export const RECONCILE_DIMS_MISMATCH_CHECKS = 2 +export const RECONCILE_DIMS_KICK_GAP_MS = 30_000 +export const RECONCILE_ERROR_LOG_GAP_MS = 60_000 export interface ReconcileSnapshot { rows: number @@ -73,6 +79,20 @@ export interface TerminalReconcilerDeps { activitySerial(): number /** Force rAF-staged chunks out so the serial reflects everything received. */ flushPending(): void + /** + * The PTY and the rendered grid have disagreed on dimensions for + * RECONCILE_DIMS_MISMATCH_CHECKS consecutive quiet checks. The dims gate + * exists to skip repairs while a resize is propagating — but a PERSISTENT + * mismatch means the size-sync loop lost the PTY: the TUI frames its + * repaints for the wrong row count (the exact state that creates + * stacked-frame corruption), and the gate would otherwise disable this + * backstop silently, forever. Wire this to a forced size resync. + * Rate-limited by RECONCILE_DIMS_KICK_GAP_MS. + */ + onPersistentDimsMismatch?( + grid: { rows: number; cols: number }, + snapshot: { rows: number; cols: number } + ): void log?(message: string): void now?(): number } @@ -90,6 +110,9 @@ export function createTerminalReconciler(deps: TerminalReconcilerDeps): Terminal let disposed = false let checking = false let mismatchStreak = 0 + let dimsMismatchStreak = 0 + let lastDimsKickAt = 0 + let lastErrorLogAt = 0 let lastRepairAt = 0 let repairCount = 0 @@ -103,6 +126,13 @@ export function createTerminalReconciler(deps: TerminalReconcilerDeps): Terminal checking = true try { await checkInner() + } catch (err) { + // A thrown check must never kill the interval loop (snapshotTerminal + // degrades to null, but the IPC bridge itself can still reject). + if (now() - lastErrorLogAt >= RECONCILE_ERROR_LOG_GAP_MS) { + lastErrorLogAt = now() + log(`[terminal] reconcile check failed: ${String(err)}`) + } } finally { checking = false } @@ -117,7 +147,24 @@ export function createTerminalReconciler(deps: TerminalReconcilerDeps): Terminal if (deps.activitySerial() !== serial || !deps.isQuiet()) return const viewport = deps.readViewport() if (!viewport) return - if (plain.rows !== viewport.rows || plain.cols !== viewport.cols) return + if (plain.rows !== viewport.rows || plain.cols !== viewport.cols) { + dimsMismatchStreak += 1 + if ( + dimsMismatchStreak >= RECONCILE_DIMS_MISMATCH_CHECKS && + now() - lastDimsKickAt >= RECONCILE_DIMS_KICK_GAP_MS + ) { + lastDimsKickAt = now() + log( + `[terminal] PTY ${plain.rows}x${plain.cols} disagrees with grid ${viewport.rows}x${viewport.cols} across ${dimsMismatchStreak} quiet checks; forcing size resync` + ) + deps.onPersistentDimsMismatch?.( + { rows: viewport.rows, cols: viewport.cols }, + { rows: plain.rows, cols: plain.cols } + ) + } + return + } + dimsMismatchStreak = 0 if (screensMatch(plain.screen, viewport.lines)) { mismatchStreak = 0 return diff --git a/src/renderer/src/lib/terminal-runtime-registry.ts b/src/renderer/src/lib/terminal-runtime-registry.ts index a6c7c0ff..a92c5a45 100644 --- a/src/renderer/src/lib/terminal-runtime-registry.ts +++ b/src/renderer/src/lib/terminal-runtime-registry.ts @@ -93,6 +93,15 @@ const TERMINAL_FONT_FAMILY = "'JetBrains Mono', 'Fira Code', 'SF Mono', Menlo, monospace" const IDLE_DISPOSE_MS = 5 * 60_000 const IDLE_SWEEP_MS = 30_000 +// Outstanding optimistic-echo predictions close the reconciler's quiet gate +// (the screen comparison would see unconfirmed glyphs). But a prediction is +// only ever confirmed or dropped by server output — if none arrives for this +// long, the echo is never coming (keystroke swallowed by the TUI, or typed +// into a hung agent) and the gate would stay closed FOREVER, silently +// disabling the convergence backstop. Rolling back only erases the +// optimistic glyphs; confirmed bytes are untouched, and a real echo that +// arrives later repaints the characters authoritatively. +export const STRANDED_PREDICTION_ROLLBACK_MS = 10_000 // Default-on, demoted to DOM after the first webgl failure for the rest of // the session. We don't recover: if webgl construction blew up once we @@ -367,8 +376,32 @@ function createRuntime( // A hidden window stalls the rAF chunk flush, so "no recent output" // says nothing about what is actually pending — never reconcile there. if (document.visibilityState !== 'visible') return false - if (predictiveEcho?.hasPredictions) return false - return Date.now() - lastOutputAt >= RECONCILE_QUIET_MS + const sinceOutput = Date.now() - lastOutputAt + if (predictiveEcho?.hasPredictions) { + if (sinceOutput < STRANDED_PREDICTION_ROLLBACK_MS) return false + // See STRANDED_PREDICTION_ROLLBACK_MS: the confirming echo is never + // coming; without this escape the quiet gate never reopens. + console.warn( + '[terminal] rolling back stranded optimistic-echo predictions (no confirming output)' + ) + predictiveEcho.rollback() + if (predictiveEcho.hasPredictions) return false + } + return sinceOutput >= RECONCILE_QUIET_MS + }, + onPersistentDimsMismatch: () => { + // The size-sync loop believes the PTY is at the last acked size, but + // the broker snapshot says otherwise. Invalidate the ack so the + // re-send isn't swallowed by the change gate, refit against the live + // container, and re-assert the rendered grid as the PTY size. + sizeSync.invalidateAck() + const size = tryFit() + if (size) { + predictiveEcho?.onResize(size.cols, size.rows) + sizeSync.setDesired(size.rows, size.cols) + } else if (term && term.rows > 0 && term.cols > 0) { + sizeSync.setDesired(term.rows, term.cols) + } }, activitySerial: () => activitySerial, flushPending: () => flushPtyChunksNow(key), From 6cbb22925c1d5188e862f7303439f00222509485 Mon Sep 17 00:00:00 2001 From: Khaliq Date: Thu, 11 Jun 2026 14:14:14 +0200 Subject: [PATCH 2/6] fix(integrations): write remote files at the live revision; allow read-only /linear/states Changing a Linear issue status from the Issues tab always failed with a 409 revision conflict: writeRemoteFile hardcoded baseRevision '0' ("file must not exist"), which is right for Slack writeback creates but wrong for PATCH writebacks onto existing canonical files. Now reads the file's current revision first ('0' only when the read 404s) and retries exactly once if a concurrent sync bumps the revision mid-write. The Issues tab also lists /linear/states for the status dropdown, which the mount-scope check rejected on every load (integrations configure mounts like /linear/issues; the states subtree is reference data, not a configurable mount). Add a read-only carve-out mirroring the Slack DM one: list/read allowed when a Linear integration is visible, writes still rejected. Co-Authored-By: Claude Fable 5 --- node_modules | 1 + src/main/integration-remote-paths.ts | 12 +++ src/main/integrations.test.ts | 108 ++++++++++++++++++++++++++- src/main/integrations.ts | 48 +++++++++++- 4 files changed, 162 insertions(+), 7 deletions(-) create mode 120000 node_modules diff --git a/node_modules b/node_modules new file mode 120000 index 00000000..d15d2127 --- /dev/null +++ b/node_modules @@ -0,0 +1 @@ +/Users/khaliqgant/Projects/AgentWorkforce/pear/node_modules \ No newline at end of file diff --git a/src/main/integration-remote-paths.ts b/src/main/integration-remote-paths.ts index 55646d5b..3c30961b 100644 --- a/src/main/integration-remote-paths.ts +++ b/src/main/integration-remote-paths.ts @@ -136,3 +136,15 @@ export function isSlackDmListablePath(remotePath: string): boolean { if (segments[1] === 'channels' && /^D/u.test(segments[2] ?? '')) return true return false } + +// The adapter-linear `states` resource materializes the team's workflow +// states at /linear/states, but Linear integrations configure mount scopes +// like /linear/issues — the states subtree is reference data alongside those +// mounts, not a configurable mount of its own, so the mount-path scope checks +// above reject it. Recognize exactly that subtree so list/read (never write) +// can be allowed when a Linear integration is visible, mirroring the Slack DM +// carve-out. +export function isLinearStatesListablePath(remotePath: string): boolean { + const segments = (remotePath || '').trim().replace(/\/+$/, '').split('/').filter(Boolean) + return segments[0] === 'linear' && segments[1] === 'states' +} diff --git a/src/main/integrations.test.ts b/src/main/integrations.test.ts index 486ab766..87a7805f 100644 --- a/src/main/integrations.test.ts +++ b/src/main/integrations.test.ts @@ -62,7 +62,11 @@ const mock = vi.hoisted(() => { writeFile: vi.fn(async (input: { workspaceId: string; path: string; baseRevision: string; content: string }) => { writeFileCalls.push(input) return { opId: 'op-1', status: 'queued' } - }) + }), + listTree: vi.fn(async (_workspaceId: string, _options: { path: string; depth?: number; cursor?: string }) => ({ + entries: [] as Array<{ path: string; type: 'file' | 'dir' }>, + nextCursor: null as string | null + })) } const shellOpenExternal = vi.fn(async () => undefined) const workspaceHandle = { @@ -335,6 +339,7 @@ describe('IntegrationsManager', () => { mock.joinWorkspaceCalls.splice(0) mock.relayClient.readFile.mockClear() mock.relayClient.writeFile.mockClear() + mock.relayClient.listTree.mockClear() mock.shellOpenExternal.mockClear() mock.workspaceHandle.requestJson.mockReset() mock.workspaceHandle.requestJson.mockImplementation(async (_request: { path: string }) => { @@ -386,17 +391,23 @@ describe('IntegrationsManager', () => { ) }) - it('writes remote files through Relayfile using the configured project scope', async () => { + it('writes remote files at the current revision (read-then-write)', async () => { const manager = new IntegrationsManager() await expect(manager.writeRemoteFile('project-1', '/slack/channels/C123/messages/draft.json', '{"text":"hello"}')) .resolves.toBeUndefined() + // The file exists remotely (mock read returns rev-1), so the write must + // carry that revision — not '0', which the server rejects with a 409 for + // any existing file (e.g. Issues status changes updating the issue record). + expect(mock.readFileCalls).toEqual([ + { workspaceId: 'account-workspace-id', path: '/slack/channels/C123/messages/draft.json' } + ]) expect(mock.writeFileCalls).toEqual([ { workspaceId: 'account-workspace-id', path: '/slack/channels/C123/messages/draft.json', - baseRevision: '0', + baseRevision: 'rev-1', content: '{"text":"hello"}' } ]) @@ -411,6 +422,97 @@ describe('IntegrationsManager', () => { ]) }) + it('creates a new remote file with baseRevision 0 when the pre-write read 404s', async () => { + mock.relayClient.readFile.mockRejectedValueOnce( + Object.assign(new Error('not found'), { status: 404 }) + ) + const manager = new IntegrationsManager() + + await expect(manager.writeRemoteFile('project-1', '/slack/channels/C123/messages/draft.json', '{"text":"hi"}')) + .resolves.toBeUndefined() + + expect(mock.writeFileCalls).toEqual([ + expect.objectContaining({ baseRevision: '0', content: '{"text":"hi"}' }) + ]) + }) + + it('re-reads and retries exactly once on a revision conflict', async () => { + const fileAt = (revision: string) => ({ + path: '/slack/channels/C123/messages/draft.json', + revision, + contentType: 'application/json', + content: '{}', + encoding: 'utf-8' as const + }) + mock.relayClient.readFile + .mockResolvedValueOnce(fileAt('rev-1')) + .mockResolvedValueOnce(fileAt('rev-2')) + mock.relayClient.writeFile.mockRejectedValueOnce( + Object.assign(new Error('revision conflict'), { status: 409, code: 'revision_conflict' }) + ) + const manager = new IntegrationsManager() + + await expect(manager.writeRemoteFile('project-1', '/slack/channels/C123/messages/draft.json', '{"a":1}')) + .resolves.toBeUndefined() + + expect( + mock.relayClient.writeFile.mock.calls.map(([input]) => (input as { baseRevision: string }).baseRevision) + ).toEqual(['rev-1', 'rev-2']) + }) + + it('does not swallow a second consecutive revision conflict', async () => { + const conflict = () => + Object.assign(new Error('revision conflict'), { status: 409, code: 'revision_conflict' }) + mock.relayClient.writeFile.mockRejectedValueOnce(conflict()).mockRejectedValueOnce(conflict()) + const manager = new IntegrationsManager() + + await expect(manager.writeRemoteFile('project-1', '/slack/channels/C123/messages/draft.json', '{}')) + .rejects.toMatchObject({ status: 409 }) + expect(mock.relayClient.writeFile).toHaveBeenCalledTimes(2) + }) + + it('allows /linear/states list + read when a Linear integration is visible, but never write', async () => { + mock.store.projects[0].integrations.push({ + id: 'linear-integration-1', + name: 'Linear', + type: 'linear', + provider: 'linear', + integrationId: 'linear-integration-1', + scope: {}, + mountPaths: ['/linear/issues'], + connectedAt: '2026-06-05T00:00:00.000Z', + notifyAgent: true, + subscribeAgent: false, + downloadHistoricalData: false, + visibleInProject: true + }) + mock.relayClient.listTree.mockResolvedValueOnce({ + entries: [ + { path: '/linear/states', type: 'dir' }, + { path: '/linear/states/state-1.json', type: 'file' } + ], + nextCursor: null + }) + const manager = new IntegrationsManager() + + await expect(manager.listRemoteDirectory('project-1', '/linear/states')).resolves.toEqual([ + { name: 'state-1.json', path: '/linear/states/state-1.json', type: 'file' } + ]) + await expect(manager.readRemoteFile('project-1', '/linear/states/state-1.json')) + .resolves.toMatchObject({ kind: 'text' }) + // The carve-out is read-only: workflow states are reference data. + await expect(manager.writeRemoteFile('project-1', '/linear/states/state-1.json', '{}')) + .rejects.toThrow('Integration remote file is outside this project integration scope') + }) + + it('rejects /linear/states listing without a visible Linear integration', async () => { + const manager = new IntegrationsManager() + + await expect(manager.listRemoteDirectory('project-1', '/linear/states')) + .rejects.toThrow('Integration remote directory is outside this project integration scope') + expect(mock.relayClient.listTree).not.toHaveBeenCalled() + }) + it('rejects remote writes outside the configured project scope', async () => { const manager = new IntegrationsManager() diff --git a/src/main/integrations.ts b/src/main/integrations.ts index f79c8404..4ef02843 100644 --- a/src/main/integrations.ts +++ b/src/main/integrations.ts @@ -16,6 +16,7 @@ import { canListRemoteDirectoryForMountPaths, canShowRemoteDirectoryEntryForMountPaths, isRelayfilePathWithinRoot, + isLinearStatesListablePath, isSlackDmListablePath, normalizeRemoteDirectoryPath, remotePathName @@ -1111,7 +1112,8 @@ export class IntegrationsManager { const allowSlackDms = this.slackDmListingEnabledForProject(projectId) const withinScope = mountPaths.some((mountPath) => isRelayfilePathWithinRoot(mountPath, path)) || - (allowSlackDms && isSlackDmListablePath(path)) + (allowSlackDms && isSlackDmListablePath(path)) || + (this.linearStatesListingEnabledForProject(projectId) && isLinearStatesListablePath(path)) if (!withinScope) { throw new Error('Integration remote file is outside this project integration scope') } @@ -1153,7 +1155,30 @@ export class IntegrationsManager { } await this.withIntegrationRemoteWriterHandle(async (handle) => { - await handle.client().writeFile({ workspaceId: handle.workspaceId, path, baseRevision: '0', content }) + const client = handle.client() + // The server enforces optimistic concurrency: a write lands only when + // baseRevision matches the file's current revision, where '0' means + // "must not exist yet". Creates (e.g. Slack writeback message files) + // pass on '0', but updates to existing canonical files (e.g. an Issues + // status change writing stateId onto the issue record) need the live + // revision — read it first, and retry once if a concurrent sync bumps + // the file between our read and write. + const writeAtCurrentRevision = async (): Promise => { + let baseRevision = '0' + try { + const existing = await client.readFile(handle.workspaceId, path) + baseRevision = existing.revision + } catch (error) { + if (!isHttpStatus(error, 404)) throw error + } + await client.writeFile({ workspaceId: handle.workspaceId, path, baseRevision, content }) + } + try { + await writeAtCurrentRevision() + } catch (error) { + if (!isHttpStatus(error, 409)) throw error + await writeAtCurrentRevision() + } }) } @@ -1163,7 +1188,12 @@ export class IntegrationsManager { if (!path || path === '/') throw new Error('Integration remote directory path is required') const mountPaths = this.listableRemoteMountPaths(projectId) const allowSlackDms = this.slackDmListingEnabledForProject(projectId) - if (!canListRemoteDirectoryForMountPaths(path, mountPaths) && !(allowSlackDms && isSlackDmListablePath(path))) { + const allowLinearStates = this.linearStatesListingEnabledForProject(projectId) + if ( + !canListRemoteDirectoryForMountPaths(path, mountPaths) && + !(allowSlackDms && isSlackDmListablePath(path)) && + !(allowLinearStates && isLinearStatesListablePath(path)) + ) { throw new Error('Integration remote directory is outside this project integration scope') } @@ -1182,7 +1212,8 @@ export class IntegrationsManager { if (entry.path === path) continue if ( !canShowRemoteDirectoryEntryForMountPaths(entry.path, mountPaths) && - !(allowSlackDms && isSlackDmListablePath(entry.path)) + !(allowSlackDms && isSlackDmListablePath(entry.path)) && + !(allowLinearStates && isLinearStatesListablePath(entry.path)) ) { continue } @@ -2301,6 +2332,15 @@ export class IntegrationsManager { return this.visibleIntegrationsForProject(projectId).some((integration) => slackListenDms(integration)) } + // True when any visible Linear integration exists for the project. The + // /linear/states reference subtree is listable/readable (never writable) + // under exactly this condition — see isLinearStatesListablePath. + private linearStatesListingEnabledForProject(projectId: string): boolean { + return this.visibleIntegrationsForProject(projectId).some( + (integration) => toRelayfileProvider(integration.provider) === 'linear' + ) + } + private async listSystemMessageAgents( bridge: IntegrationSystemMessageBridge, projectId: string, From ca884b8b00ffcba6093f34d2cba8be2b28f58408 Mon Sep 17 00:00:00 2001 From: "agent-relay-code[bot]" Date: Thu, 11 Jun 2026 12:24:40 +0000 Subject: [PATCH 3/6] chore: apply pr-reviewer fixes for #226 --- node_modules | 1 - 1 file changed, 1 deletion(-) delete mode 120000 node_modules diff --git a/node_modules b/node_modules deleted file mode 120000 index d15d2127..00000000 --- a/node_modules +++ /dev/null @@ -1 +0,0 @@ -/Users/khaliqgant/Projects/AgentWorkforce/pear/node_modules \ No newline at end of file From f3defd854d188e06d15411f35ac624e7f86a1282 Mon Sep 17 00:00:00 2001 From: "agent-relay-code[bot]" Date: Thu, 11 Jun 2026 12:48:36 +0000 Subject: [PATCH 4/6] chore: apply pr-reviewer fixes for #226 --- src/main/integrations.test.ts | 2 +- src/main/integrations.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/integrations.test.ts b/src/main/integrations.test.ts index 87a7805f..145d8928 100644 --- a/src/main/integrations.test.ts +++ b/src/main/integrations.test.ts @@ -416,7 +416,7 @@ describe('IntegrationsManager', () => { workspaceId: 'account-workspace-id', options: { agentName: 'pear-integrations-writer', - scopes: ['relayfile:fs:write:/**'] + scopes: ['relayfile:fs:read:/**', 'relayfile:fs:write:/**'] } } ]) diff --git a/src/main/integrations.ts b/src/main/integrations.ts index 4ef02843..47554a51 100644 --- a/src/main/integrations.ts +++ b/src/main/integrations.ts @@ -1630,7 +1630,7 @@ export class IntegrationsManager { }) const handle = await setup.joinWorkspace(workspaceId, { agentName: mode === 'read' ? 'pear-integrations-reader' : 'pear-integrations-writer', - scopes: mode === 'read' ? ['relayfile:fs:read:/**'] : ['relayfile:fs:write:/**'] + scopes: mode === 'read' ? ['relayfile:fs:read:/**'] : ['relayfile:fs:read:/**', 'relayfile:fs:write:/**'] }) if (mode === 'read') this.integrationRemoteReaderHandle = handle else this.integrationRemoteWriterHandle = handle From 79e623974315c55629d3a795ac9ce9bd9124fb58 Mon Sep 17 00:00:00 2001 From: "agent-relay-code[bot]" Date: Thu, 11 Jun 2026 12:52:34 +0000 Subject: [PATCH 5/6] chore: apply pr-reviewer fixes for #226 --- src/renderer/src/lib/terminal-reconciler.test.ts | 15 +++++++++++++++ src/renderer/src/lib/terminal-reconciler.ts | 1 + 2 files changed, 16 insertions(+) diff --git a/src/renderer/src/lib/terminal-reconciler.test.ts b/src/renderer/src/lib/terminal-reconciler.test.ts index 03fa2f2d..3a1321c5 100644 --- a/src/renderer/src/lib/terminal-reconciler.test.ts +++ b/src/renderer/src/lib/terminal-reconciler.test.ts @@ -162,6 +162,21 @@ describe('createTerminalReconciler', () => { reconciler.dispose() }) + it('requires fresh divergence confirmations after a dims-mismatch skip', async () => { + const { deps, repairs, state } = makeHarness() + const reconciler = createTerminalReconciler(deps) + diverge(state) + await reconciler.checkNow() + state.viewport = { rows: 3, cols: 10, lines: ['row one', 'GARBAGE tw', ''] } + await reconciler.checkNow() + state.viewport = { rows: 2, cols: 10, lines: ['row one', 'GARBAGE tw'] } + await reconciler.checkNow() + expect(repairs).toEqual([]) + await reconciler.checkNow() + expect(repairs).toHaveLength(1) + reconciler.dispose() + }) + it('escalates a persistent dims mismatch to onPersistentDimsMismatch, rate-limited', async () => { const { deps, repairs, state } = makeHarness() const kicks: Array<{ grid: { rows: number; cols: number }; snapshot: { rows: number; cols: number } }> = [] diff --git a/src/renderer/src/lib/terminal-reconciler.ts b/src/renderer/src/lib/terminal-reconciler.ts index ce621f45..fbe512f9 100644 --- a/src/renderer/src/lib/terminal-reconciler.ts +++ b/src/renderer/src/lib/terminal-reconciler.ts @@ -148,6 +148,7 @@ export function createTerminalReconciler(deps: TerminalReconcilerDeps): Terminal const viewport = deps.readViewport() if (!viewport) return if (plain.rows !== viewport.rows || plain.cols !== viewport.cols) { + mismatchStreak = 0 dimsMismatchStreak += 1 if ( dimsMismatchStreak >= RECONCILE_DIMS_MISMATCH_CHECKS && From 6bda881775122eea0f700a4b367c5c6351f2bccd Mon Sep 17 00:00:00 2001 From: Khaliq Date: Thu, 11 Jun 2026 16:07:05 +0200 Subject: [PATCH 6/6] fix(test): add waitForSent to resource alias dedup test to avoid CI timing flake The pacer drains via setTimeout(0); on Linux CI the assertion raced it. Matches the pattern used in the adjacent revision-dedup test. Co-Authored-By: Claude Sonnet 4.6 --- src/main/__tests__/integration-event-bridge.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/__tests__/integration-event-bridge.test.ts b/src/main/__tests__/integration-event-bridge.test.ts index 1ee905b2..6b03a155 100644 --- a/src/main/__tests__/integration-event-bridge.test.ts +++ b/src/main/__tests__/integration-event-bridge.test.ts @@ -2344,6 +2344,7 @@ test('resource alias mount paths inject the same relative event only once', asyn await harness.emit(changeEvent('/chat/channels/C123ABC/threads/1780607825_485189/replies/1780611452_510669.json', 'chat')) await harness.emit(changeEvent('/chat/channels/C123ABC__proj-cloud/threads/1780607825_485189/replies/1780611452_510669.json', 'chat')) + await waitForSent(harness, 1) assert.deepEqual(harness.sent.map((message) => message.input.to), ['alice']) })