diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 7c6725884..148784449 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1783,11 +1783,11 @@ Raw byte dispatch is centralized so mounting another UI consumer does not create The application uses its bundled tmux through a dedicated registry. It does not opportunistically attach to a user's unrelated system tmux sessions. Managed names/prefixes constrain reconciliation and cleanup. If bundled tmux is unavailable, ordinary terminals fall back to direct PTY and lose that process-persistence capability. -Reconciliation compares persisted terminal references with managed live sessions: known/live sessions are recoverable, known/dead sessions are lost, and unreferenced managed sessions are treated as orphans and killed. This policy makes the correctness of the persisted-reference reader critical. +Reconciliation compares persisted terminal references across every saved window with managed live sessions: known/live sessions are recoverable and known/dead sessions are lost. Only a complete inventory permits treating unreferenced managed sessions as orphans and killing them. A complete inventory may be genuinely empty; missing, unreadable, future-version, or partially decoded state cannot establish that absence of references. -**Current integration discrepancy:** main startup reads `parsed.workspace.sessions` to obtain tmux references, but `WorkspaceFileStore` writes the version-2 `windows[].workspace` envelope. For a normal v2 file that legacy read yields no references. With managed tmux sessions present, reconciliation can classify them as orphans. The persistence intention and the current multi-window startup behavior therefore differ; this reference does not claim reliable tmux survival across that path. +Startup uses the same canonical workspace decoder as restoration, including legacy v1 migration and v2 `windows[].workspace` decoding. The decoder reports discarded/duplicate windows and invalid window containers; terminal inventory separately checks the narrow saved session/reference fields it consumes. Incomplete inventory can recover known references while preserving unmatched live names. Geometry repair alone does not weaken the inventory. Startup diagnostics report completeness, bounded issue counts, preserved-name counts, and a SHA-256 digest of the inspected file bytes without recording terminal content. A failed read has no digest; it is not represented as an empty file snapshot. -The mismatch is directly visible in [startup reconciliation](src/main/index.ts), [workspace format](src/main/storage/workspaceFile.ts), and [tmux reconciliation](src/main/tmux/tmuxRecovery.ts), and tracked in [issue #898](https://github.com/Juliusolsson05/agent-code/issues/898). No runtime change is part of this documentation work. +This corrects the legacy-only startup read tracked in [issue #898](https://github.com/Juliusolsson05/agent-code/issues/898). See [startup reconciliation](src/main/index.ts), [workspace format](src/main/storage/workspaceFile.ts), [recovery boundary](src/main/tmux/tmuxRecovery.ts), and [composed regression tests](src/main/tmux/tmuxRecovery.test.ts). Preservation is based on the file read for this startup; it does not create a durable quarantine or repair history for references removed by later saves. #### 6.6.2 xterm lifecycle and patched dependency @@ -3050,7 +3050,7 @@ Sources: [Vitest configuration](vitest.config.ts), [live configuration](vitest.l | Area | Current constraint | Consequence for changes | | --- | --- | --- | -| tmux restart recovery | Startup reference extraction reads the legacy envelope | Fix v2 extraction before asserting multi-window terminal persistence; tracked in #898 | +| tmux restart recovery | A structurally complete workspace file is treated as proof that unmatched managed sessions are abandoned, but a file can be complete and still STALE: terminals created during a partial-restore run (autosave off), inside the 400 ms autosave debounce before a crash, or by a second app sharing state (#993) are never saved and get cleaned on the next launch. #898's v2 extraction is fixed; this narrower gap remains | Never kill a session whose tmux `createdAt` is newer than the inspected file's mtime (margin for whole-second timestamps); durable quarantine/repair provenance in #918 | | OpenCode | Structured and terminal runtimes have different observation/input contracts; saved-session listing is unavailable | Avoid one generic capability flag that promises all surfaces | | Prompt acceptance | Claude durable evidence differs from Codex/OpenCode transport acceptance | Preserve acceptance kinds and retry dispositions end to end | | Codex ownership | Native rollout attachment/replacement requires leases and compensation | Do not select by newest filename or blindly start a second writer | diff --git a/docs/superpowers/plans/2026-09-12-safe-tmux-workspace-recovery.md b/docs/superpowers/plans/2026-09-12-safe-tmux-workspace-recovery.md new file mode 100644 index 000000000..4805d1af0 --- /dev/null +++ b/docs/superpowers/plans/2026-09-12-safe-tmux-workspace-recovery.md @@ -0,0 +1,54 @@ +# Safe tmux workspace recovery + +Status: implementation complete, independently reviewed GREEN (2026-09-19), CI green; merging via PR #933. First implementation-loop slice of B00/B01 in [program #918](https://github.com/Juliusolsson05/agent-code/issues/918), following [plan PR #931](https://github.com/Juliusolsson05/agent-code/pull/931). Resolves [#898](https://github.com/Juliusolsson05/agent-code/issues/898) through its implementation PR. + +Baseline: `552914f5610518458f944fa1bdaf63f243685bd1`. Source still reads the legacy workspace envelope before tmux reconciliation; current persistence uses v2 windows. No other open tmux PR was found at intake. The original checkout's untracked plan and other worktrees remain outside this change. + +## Intended contract + +Startup and ordinary restoration use the same workspace decoder. The decoder reports whether it discarded any potentially relevant envelope/window data. Resource inventory separately validates the terminal-reference shapes it consumes and distinguishes complete inventory (including genuinely empty) from incomplete or unknown inventory. + +Only a complete inventory permits existing managed-orphan cleanup. Incomplete data may recover explicitly known live terminals and report missing references, but unmatched live sessions remain preserved. A missing/unreadable/future/corrupt workspace does not establish that a previously managed process is an orphan. Window geometry repair alone need not imply missing terminal ownership. Exact reference collection spans all retained windows and avoids duplicating equivalent tmux references. + +This is a narrow resource inventory projection, not a second implementation of renderer pane ownership or the future main catalog. Ordinary workspace restoration behavior remains compatible; completeness metadata prevents its partial recovery from accidentally authorizing destruction. + +## Ordered work + +1. Initialize all seven package pins and an isolated dependency install; run focused existing workspace tests before changes. +2. Add composed fixture tests that call the startup inventory/decoder path and real reconciliation with a fake registry. Protect v2 multi-window and legacy recovery, complete empty inventory, malformed/future/read failures, discarded or duplicate window records, malformed session metadata, and unrelated managed/nonmanaged names under the registry contract. +3. Extend canonical decode results with explicit completeness evidence, implement bounded reason/count reporting for terminal inventory, and require that evidence at reconciliation admission. +4. Replace main startup's ad hoc JSON envelope read with the canonical inventory path; record preserved unmatched counts and completeness in diagnostics without terminal content. +5. Review source/store callers for compatibility, update the architecture's directly affected known limitation, and run focused tests, type checking, test-contract and build/package checks appropriate to the change. No real user tmux sessions are killed as a test. +6. Commit the implementation, open a complete Conventional Commit PR linked to #898/#918, inspect CI and resolve valid review findings. Do not merge without user authorization. + +## Acceptance and rollback + +Tests must fail against the old integration and prove that recognized v2 references survive; checking a decoder alone cannot prove cleanup safety. Only explicitly complete inventory can classify unmatched managed sessions as destructible. Invalid windows or session regions must never become an empty authoritative set. Existing restoration and independent-window persistence tests remain green. + +No persistent format bump is required for additive decode evidence. Rollback can disable orphan cleanup, but cannot reinstate unknown-as-empty behavior. Preserve useful diagnostics and mark unexercised native/process behavior explicitly. Lasting WHY comments explain why partial UI restoration is weaker than destructive resource inventory. + +## Implemented boundary and evidence + +`reconcileWorkspace` is now the startup entry point, with file access supplied by main. It calls the same `parseWorkspaceFile` used by restoration. Envelope completeness is additive decode metadata, never a new on-disk field. Reference validation includes legacy agents, direct-PTY terminals without a tmux name, duplicate exact references, and conflicting references across windows. Only a complete inventory reaches orphan cleanup; incomplete/unknown results expose bounded issue counts and preserved names. Registry failures still reject instead of being relabeled as workspace uncertainty. + +The existing 20 workspace decoder/store tests passed before implementation. Extracting startup's legacy read into the tested boundary without changing its behavior produced 21 failures among the first 25 regression cases: v2 references were lost, partially readable inventories reached cleanup, and unreadable/read failures lacked the new explicit uncertainty report. Missing/corrupt files previously escaped to startup's catch, so those reporting failures are not evidence of previous deletion on every read error. + +After implementation, 58 tests pass across the recovery, registry, workspace decoder, and workspace store suites, including 36 new composed recovery cases. `npm run typecheck`, `npm run test:contract`, `npm run submodules:check` (all seven pins), and `npm run test:package` pass. The build verifier covers app entry points; cached runtime archives were absent, so this is not a signed release or bundled-tmux smoke test. No live user terminal was launched, attached, or killed. + +Review retained the existing registry prefix boundary and store restoration policy. Preservation is scoped to the startup snapshot: this slice does not persist a quarantine across later saves that remove damaged metadata. Durable repair provenance belongs to the subsequent storage/recovery work in #918; it must not be inferred from these startup tests. Unknown-owner routing (#920) remains a separate B01 PR. + +Program-plan conformance review added a SHA-256 receipt of the exact workspace bytes inspected, including corrupt/future files; read failures carry no digest. The receipt is emitted with the cleanup decision so a later autosave cannot erase its snapshot identity. The original implementation passed both CI gates at `ff9724de`; the receipt follow-up passed the updated 58-test suite and type checking locally and receives a new CI run. + +**2026-09-19 update (release-readiness).** +- The branch was refreshed onto main at `93f60755`. That was 340 commits, and the + only conflict was in the imports of `src/main/index.ts`; startup order is unchanged. +- `b3961fba` adds a regression test on a REAL persisted v2 `workspace.json`, sanitized, + under `testing/fixtures/workspace-v2/`. Running the pre-PR startup read and `reconcile` + on that same file kills its detached terminal. The fixed path preserves it and still + cleans a proven orphan. +- Suite: 37 recovery cases; 79/79 across `src/main/tmux` and `src/main/storage`. +- An independent reviewer found a remaining, narrower gap: a complete but stale file + (after a partial restore, a crash inside the autosave debounce, or two apps + sharing state, #993). It is recorded in ARCHITECTURE.md §11 and on #918, with the fix + direction: never kill a session created after the inspected file's mtime. + diff --git a/src/main/index.ts b/src/main/index.ts index 542775e0e..0aaea4c45 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -53,8 +53,7 @@ import { pruneOldPasteDebugLogs, } from '@main/pasteDebugJournal.js' import { TmuxRegistry } from '@main/tmux/TmuxRegistry.js' -import { reconcile } from '@main/tmux/tmuxRecovery.js' -import type { PersistedTerminalRef } from '@main/tmux/tmuxRecovery.js' +import { reconcileWorkspace } from '@main/tmux/tmuxRecovery.js' import { handleExtensionScheme, @@ -836,49 +835,35 @@ async function startApp(): Promise { if (tmuxAvailable) { try { appRunJournal.record({ area: 'app.tmux', name: 'tmux.recovery.start' }) - const raw = await readFile(STATE_FILE, 'utf8') - // workspace.json is wrapped: { workspace: { sessions: {...} } }. - // The renderer's saveWorkspace() writes { workspace: workspaceState } - // — so persisted sessions live one level deep, not at the root. - // Reading parsed.sessions directly (as the original code did) - // always returned undefined, which is why recovery silently - // reported "0 recoverable" even when tmuxName WAS persisted. - const parsed = JSON.parse(raw) as { - workspace?: { - sessions?: Record - } - } - const persisted: PersistedTerminalRef[] = Object.entries( - parsed.workspace?.sessions ?? {}, - ) - .filter(([, meta]) => meta?.kind === 'terminal' && typeof meta?.tmuxName === 'string') - .map(([sessionId, meta]) => ({ sessionId, tmuxName: meta!.tmuxName! })) - const recoveryReport = await reconcile(tmuxRegistry, persisted) - performanceService.mark('app.tmux.recovery.complete', { + // Use restoration's canonical envelope decoder, including its evidence + // of discarded windows. A successful partial restore cannot authorize + // deleting a terminal whose only reference was in the discarded region. + const recoveryReport = await reconcileWorkspace(tmuxRegistry, () => readFile(STATE_FILE, 'utf8')) + const recoverySummary = { + inventory: recoveryReport.inventory, + inventoryIssues: recoveryReport.inventoryIssues, + inventoryDigest: recoveryReport.inventoryDigest, recoverable: recoveryReport.recoverable.length, lost: recoveryReport.lost.length, orphans: recoveryReport.orphans.length, - }) + preserved: recoveryReport.preserved.length, + } + performanceService.mark('app.tmux.recovery.complete', recoverySummary) appRunJournal.record({ area: 'app.tmux', name: 'tmux.recovery.end', - data: { - recoverable: recoveryReport.recoverable.length, - lost: recoveryReport.lost.length, - orphans: recoveryReport.orphans.length, - }, + data: recoverySummary, }) console.log( - `[tmux] recovery: ${recoveryReport.recoverable.length} recoverable, ${recoveryReport.lost.length} lost, ${recoveryReport.orphans.length} orphans cleaned`, + `[tmux] recovery (${recoveryReport.inventory} inventory): ${recoveryReport.recoverable.length} recoverable, ${recoveryReport.lost.length} lost, ${recoveryReport.orphans.length} orphans cleaned, ${recoveryReport.preserved.length} unmatched preserved`, ) } catch (err) { - // Missing/corrupt workspace.json is fine — fresh launch falls - // through with empty buckets. Log so a real failure is visible. - if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { - console.warn('[tmux] recovery failed (treating all sessions as fresh):', err) - performanceService.error('app.tmux.recovery.error', err) - appRunJournal?.recordError('tmux.recovery.error', err) - } + // Read/decode uncertainty is reported above with cleanup withheld. + // Registry or cleanup failures remain failures; do not claim every + // session was fresh or that all requested termination succeeded. + console.warn('[tmux] recovery failed:', err) + performanceService.error('app.tmux.recovery.error', err) + appRunJournal?.recordError('tmux.recovery.error', err) } } diff --git a/src/main/storage/workspaceFile.ts b/src/main/storage/workspaceFile.ts index fd85aff20..d65011613 100644 --- a/src/main/storage/workspaceFile.ts +++ b/src/main/storage/workspaceFile.ts @@ -11,11 +11,12 @@ // // So the refusal narrows rather than disappears. Main learns exactly one new // thing — which window an opaque blob belongs to — and `workspace` below stays -// `unknown`, stored and returned verbatim. Nothing in main interprets tabs, -// panes, ownership, or any other renderer concept. If you find yourself adding -// a field access into that blob, stop: the pruning/ownership logic in -// `sessionOwnership.ts` is the renderer's, and duplicating a second opinion -// about it in main is how the two get to disagree. +// `unknown`, stored and returned verbatim. Main must not reimplement tab/pane +// ownership or pruning from `sessionOwnership.ts`. Narrow resource inventories +// (such as tmux startup recovery) may read saved references from these decoded +// slices, but must respect incomplete decoding and must not decide whether a +// reference is valid from renderer layout. A hidden terminal still owns its +// process. Duplicating the renderer's ownership rules is how the two disagree. // // WHY one file instead of `workspace..json` per window: // @@ -55,8 +56,22 @@ export type WorkspaceFile = { export const WORKSPACE_FILE_VERSION = 2 +/** + * Restoring usable windows and proving a complete resource inventory are + * different promises. Dropping a damaged/duplicate window is useful for the + * former but can hide the only reference to a surviving terminal. Keep that + * evidence beside the decoded result so cleanup cannot mistake a repaired + * subset for an authoritative empty workspace. Geometry repairs and minted + * window ids preserve payloads, so they do not weaken this envelope evidence. + * This says nothing about the opaque payload's schema; its consumers validate + * the narrow reference fields they actually understand. + */ +export type WorkspaceDecodeCompleteness = + | { kind: 'complete' } + | { kind: 'partial'; invalidWindowsContainer: boolean; discardedWindows: number } + export type ParsedWorkspaceFile = - | { kind: 'ok'; file: WorkspaceFile; migratedFromV1: boolean } + | { kind: 'ok'; file: WorkspaceFile; migratedFromV1: boolean; completeness: WorkspaceDecodeCompleteness } /** * The file exists but this build cannot represent it — a NEWER version * written by a future build. @@ -146,6 +161,7 @@ export function parseWorkspaceFile( return { kind: 'ok', migratedFromV1: true, + completeness: { kind: 'complete' }, file: { version: WORKSPACE_FILE_VERSION, windows: [{ @@ -169,18 +185,33 @@ export function parseWorkspaceFile( const rawWindows = Array.isArray(parsed.windows) ? parsed.windows : [] const windows: PersistedWindow[] = [] const seen = new Set() + let discardedWindows = 0 for (const raw of rawWindows) { const window = coerceWindow(raw, mintWindowId) - if (!window) continue + if (!window) { + discardedWindows += 1 + continue + } // Duplicate ids would make `withWindowSlice` ambiguous and could let one // window's save land in another's slot. Hand-edited files are an explicit // threat model throughout this codebase; keep the first, drop the rest. - if (seen.has(window.windowId)) continue + if (seen.has(window.windowId)) { + discardedWindows += 1 + continue + } seen.add(window.windowId) windows.push(window) } - return { kind: 'ok', migratedFromV1: false, file: { version: WORKSPACE_FILE_VERSION, windows } } + const invalidWindowsContainer = !Array.isArray(parsed.windows) + return { + kind: 'ok', + migratedFromV1: false, + file: { version: WORKSPACE_FILE_VERSION, windows }, + completeness: invalidWindowsContainer || discardedWindows > 0 + ? { kind: 'partial', invalidWindowsContainer, discardedWindows } + : { kind: 'complete' }, + } } export function serializeWorkspaceFile(file: WorkspaceFile): string { diff --git a/src/main/tmux/tmuxRecovery.test.ts b/src/main/tmux/tmuxRecovery.test.ts new file mode 100644 index 000000000..aef1fb78d --- /dev/null +++ b/src/main/tmux/tmuxRecovery.test.ts @@ -0,0 +1,233 @@ +import { createHash } from 'node:crypto' +import { readFileSync } from 'node:fs' +import { describe, expect, it, vi } from 'vitest' + +import { reconcileWorkspace } from '@main/tmux/tmuxRecovery.js' + +// Exercise the entry point used by startup, through the real canonical decoder +// and real reconciliation. A decoder-only test missed #898: restoration could +// understand windows perfectly while startup fed an empty set to cleanup. +// Only the tmux process boundary is fake; no test may address the user's server. +function registryWith(...names: string[]) { + return { + isAvailable: () => true, + listManagedSessions: vi.fn(async () => names.map(name => ({ name, createdAt: 1 }))), + killSession: vi.fn(async (_name: string) => {}), + } +} + +const terminal = (tmuxName: string) => ({ kind: 'terminal', tmuxName }) +const windowWith = (windowId: string, sessions: unknown) => ({ windowId, workspace: { sessions } }) +const readJson = (value: unknown) => async () => JSON.stringify(value) + +describe('startup workspace inventory and tmux cleanup', () => { + // #898 on a REAL persisted file, not a typed literal. This is the live v2 + // workspace.json the app wrote on 2026-09-19, sanitized + // (testing/fixtures/workspace-v2/README.md). It carries one real terminal + // whose tmux session was alive. Before #933 every restart killed that + // session: startup read `parsed.workspace?.sessions`, a v1-era path that + // does not exist in the v2 envelope, handed reconciliation an empty list, + // and reconciliation killed every managed session as an orphan. + it('keeps the live terminal of a real recorded v2 workspace.json and still cleans a proven orphan (#898)', async () => { + const raw = readFileSync(new URL('../../../testing/fixtures/workspace-v2/2026-09-19-live-workspace.sanitized.json', import.meta.url), 'utf8') + const parsed = JSON.parse(raw) as { version: number, windows: { workspace: { sessions: Record } }[] } + const liveTerminals = parsed.windows.flatMap(window => Object.values(window.workspace.sessions)) + .filter(session => session.kind === 'terminal' && session.tmuxName) + .map(session => session.tmuxName!) + expect(liveTerminals.length).toBeGreaterThan(0) + // The exact legacy read that caused #898 finds NOTHING in this real file. + expect((parsed as unknown as { workspace?: { sessions?: unknown } }).workspace?.sessions).toBeUndefined() + + const registry = registryWith(...liveTerminals, 'agentcode-proven-orphan') + await reconcileWorkspace(registry, async () => raw) + + for (const name of liveTerminals) expect(registry.killSession).not.toHaveBeenCalledWith(name) + // The inventory is complete, so cleanup authority is intact: the fix + // preserves the real terminal without disabling orphan cleanup altogether. + expect(registry.killSession).toHaveBeenCalledWith('agentcode-proven-orphan') + }) + + + it('recovers every window, including parked terminals, and only cleans a proven orphan', async () => { + const registry = registryWith('agentcode-left', 'agentcode-right', 'agentcode-parked', 'agentcode-orphan') + const report = await reconcileWorkspace(registry, readJson({ + version: 2, + windows: [ + windowWith('left', { left: terminal('agentcode-left'), dead: terminal('agentcode-dead') }), + { + windowId: 'right', + // The resource projection must use all saved metadata. It must not + // infer ownership from visible leaves and kill a buried/detached shell. + workspace: { + sessions: { + right: terminal('agentcode-right'), parked: terminal('agentcode-parked'), + directPty: { kind: 'terminal' }, legacyAgent: { cwd: '/repo' }, + claude: { kind: 'claude' }, codex: { kind: 'codex' }, opencode: { kind: 'opencode' }, + }, + tabs: [], buried: [{ sessionId: 'parked' }], + }, + }, + ], + })) + expect(report.recoverable).toEqual([ + { sessionId: 'left', tmuxName: 'agentcode-left' }, + { sessionId: 'right', tmuxName: 'agentcode-right' }, + { sessionId: 'parked', tmuxName: 'agentcode-parked' }, + ]) + expect(report.lost).toEqual(['dead']) + expect(report.inventory).toBe('complete') + expect(report.inventoryIssues).toEqual({}) + expect(report.preserved).toEqual([]) + expect(registry.killSession.mock.calls).toEqual([['agentcode-orphan']]) + }) + + it('retains the legacy single-window recovery contract', async () => { + const registry = registryWith('agentcode-saved', 'agentcode-orphan') + const report = await reconcileWorkspace(registry, readJson({ + workspace: { sessions: { saved: terminal('agentcode-saved') } }, + })) + expect(report.recoverable).toEqual([{ sessionId: 'saved', tmuxName: 'agentcode-saved' }]) + expect(registry.killSession.mock.calls).toEqual([['agentcode-orphan']]) + }) + + it.each([ + { version: 2, windows: [] }, + { version: 2, windows: [windowWith('empty', {})] }, + { workspace: { sessions: {} } }, + ])('permits cleanup with an explicitly complete empty inventory: %j', async value => { + const registry = registryWith('agentcode-orphan') + const report = await reconcileWorkspace(registry, readJson(value)) + expect(report.inventory).toBe('complete') + expect(registry.killSession.mock.calls).toEqual([['agentcode-orphan']]) + }) + + it.each([ + ['missing windows', { version: 2 }], + ['invalid windows', { version: 2, windows: {} }], + ['discarded window', { version: 2, windows: [windowWith('good', { saved: terminal('agentcode-saved') }), null] }], + ['duplicate window id', { version: 2, windows: [windowWith('dup', { saved: terminal('agentcode-saved') }), windowWith('dup', { hidden: terminal('agentcode-hidden') })] }], + ['missing workspace', { version: 2, windows: [{ windowId: 'broken' }] }], + ['missing sessions', { version: 2, windows: [{ windowId: 'broken', workspace: {} }] }], + ['invalid sessions', { version: 2, windows: [windowWith('broken', [])] }], + ['invalid session row', { workspace: { sessions: { broken: null } } }], + ['unknown session kind', { workspace: { sessions: { broken: { kind: 'future' } } } }], + ['invalid terminal name', { workspace: { sessions: { broken: { kind: 'terminal', tmuxName: 42 } } } }], + ['empty terminal name', { workspace: { sessions: { broken: terminal('') } } }], + ['name on unexpected kind', { workspace: { sessions: { broken: { kind: 'claude', tmuxName: 'agentcode-hidden' } } } }], + ])('preserves unmatched managed sessions after partial recovery: %s', async (_label, value) => { + const registry = registryWith('agentcode-saved', 'agentcode-hidden') + const report = await reconcileWorkspace(registry, readJson(value)) + expect(report.inventory).toBe('incomplete') + expect(report.orphans).toEqual([]) + expect(report.preserved).toContain('agentcode-hidden') + expect(registry.killSession).not.toHaveBeenCalled() + }) + + it.each([ + '', ' ', '{"version":2,"windows":[', 'null', '[]', + JSON.stringify({ version: 99, windows: [] }), + ])('preserves managed sessions when the file is unreadable: %j', async text => { + const registry = registryWith('agentcode-preserve') + const report = await reconcileWorkspace(registry, async () => text) + expect(report).toMatchObject({ + inventory: 'unknown', inventoryIssues: { workspace_unreadable: 1 }, + orphans: [], preserved: ['agentcode-preserve'], + }) + expect(registry.killSession).not.toHaveBeenCalled() + }) + + it.each(['ENOENT', 'EACCES', 'EIO'])('does not turn a %s read failure into empty inventory', async code => { + const registry = registryWith('agentcode-preserve') + const read = async () => { throw Object.assign(new Error('fixture read failure'), { code }) } + const report = await reconcileWorkspace(registry, read) + expect(report).toMatchObject({ inventory: 'unknown', orphans: [], preserved: ['agentcode-preserve'] }) + expect(report.inventoryIssues).toEqual({ [code === 'ENOENT' ? 'workspace_missing' : 'workspace_read_failed']: 1 }) + expect(registry.killSession).not.toHaveBeenCalled() + }) + + it('recovers known references while reporting discarded windows and invalid rows as bounded counts', async () => { + const registry = registryWith('agentcode-saved', 'agentcode-hidden') + const source = { + version: 2, windows: [ + windowWith('good', { saved: terminal('agentcode-saved'), dead: terminal('agentcode-dead'), invalid: null }), + null, { windowId: 'bad' }, windowWith('good', { hidden: terminal('agentcode-hidden') }), + ], + } + const report = await reconcileWorkspace(registry, readJson(source)) + expect(report).toEqual({ + inventory: 'incomplete', inventoryIssues: { discarded_windows: 3, invalid_session_metadata: 1 }, + inventoryDigest: createHash('sha256').update(JSON.stringify(source)).digest('hex'), + recoverable: [{ sessionId: 'saved', tmuxName: 'agentcode-saved' }], + lost: ['dead'], orphans: [], preserved: ['agentcode-hidden'], + }) + expect(registry.killSession).not.toHaveBeenCalled() + }) + + it('does not weaken inventory for repaired geometry or minted window identity', async () => { + const registry = registryWith('agentcode-saved', 'agentcode-orphan') + const report = await reconcileWorkspace(registry, readJson({ + version: 2, windows: [{ bounds: { width: -1 }, workspace: { sessions: { saved: terminal('agentcode-saved') } } }], + })) + expect(report.inventory).toBe('complete') + expect(report.recoverable).toEqual([{ sessionId: 'saved', tmuxName: 'agentcode-saved' }]) + expect(registry.killSession.mock.calls).toEqual([['agentcode-orphan']]) + }) + + it('deduplicates an identical saved reference across distinct window slices', async () => { + const registry = registryWith('agentcode-saved') + const report = await reconcileWorkspace(registry, readJson({ + version: 2, windows: ['a', 'b'].map(id => windowWith(id, { saved: terminal('agentcode-saved') })), + })) + expect(report.inventory).toBe('complete') + expect(report.recoverable).toEqual([{ sessionId: 'saved', tmuxName: 'agentcode-saved' }]) + }) + + it.each([ + [windowWith('a', { same: terminal('agentcode-a') }), windowWith('b', { same: terminal('agentcode-b') })], + [windowWith('a', { first: terminal('agentcode-a') }), windowWith('b', { second: terminal('agentcode-a') })], + ])('withholds cleanup when saved session/name ownership conflicts across windows: %j', async (first, second) => { + const registry = registryWith('agentcode-a', 'agentcode-b', 'agentcode-preserve') + const report = await reconcileWorkspace(registry, readJson({ version: 2, windows: [first, second] })) + expect(report.inventoryIssues).toEqual({ conflicting_terminal_reference: 1 }) + expect(report.preserved).toContain('agentcode-preserve') + expect(registry.killSession).not.toHaveBeenCalled() + }) + + it('reports known terminals as unavailable without calling an unavailable registry', async () => { + const registry = { ...registryWith(), isAvailable: () => false } + const report = await reconcileWorkspace(registry, readJson({ workspace: { sessions: { saved: terminal('agentcode-saved') } } })) + expect(report).toMatchObject({ recoverable: [], lost: ['saved'], orphans: [], preserved: [] }) + expect(registry.listManagedSessions).not.toHaveBeenCalled() + expect(registry.killSession).not.toHaveBeenCalled() + }) + + it.each(['list', 'kill'])('keeps a registry %s failure distinct from workspace uncertainty', async operation => { + const registry = registryWith('agentcode-orphan') + const error = new Error('fixture registry failure') + if (operation === 'list') registry.listManagedSessions.mockRejectedValue(error) + else registry.killSession.mockRejectedValue(error) + await expect(reconcileWorkspace(registry, readJson({ version: 2, windows: [] }))).rejects.toBe(error) + }) + + it('identifies the captured file bytes after the source changes, even when both inventories are empty', async () => { + const registry = registryWith() + let source = '{"version":2,"windows":[]}' + const first = await reconcileWorkspace(registry, async () => source) + source = '{"workspace":{"sessions":{}}}' + const second = await reconcileWorkspace(registry, async () => source) + expect(first.inventoryDigest).toBe(createHash('sha256').update('{"version":2,"windows":[]}').digest('hex')) + expect(second.inventoryDigest).not.toBe(first.inventoryDigest) + expect(first.inventory).toBe('complete') + expect(second.inventory).toBe('complete') + }) + + it('distinguishes an unreadable byte snapshot from a failed read without retaining its contents', async () => { + const registry = registryWith() + const text = '{"private":"unfinished' + const decoded = await reconcileWorkspace(registry, async () => text) + const unread = await reconcileWorkspace(registry, async () => { throw new Error('unavailable') }) + expect(decoded.inventoryDigest).toBe(createHash('sha256').update(text).digest('hex')) + expect(unread.inventoryDigest).toBeNull() + expect(JSON.stringify(decoded)).not.toContain('unfinished') + }) +}) diff --git a/src/main/tmux/tmuxRecovery.ts b/src/main/tmux/tmuxRecovery.ts index d39c0bc9e..90b317ce2 100644 --- a/src/main/tmux/tmuxRecovery.ts +++ b/src/main/tmux/tmuxRecovery.ts @@ -1,60 +1,171 @@ -// On-launch reconciliation between persisted workspace state and the -// tmux server's view of the world. -// -// Three buckets: -// - alive + known → re-attach (don't respawn) -// - alive + orphan → P1: kill (no UI yet to surface them; P2 adds -// the undo tray which doubles as recovery surface) -// - dead + known → P1: respawn from scratch (lost state) -// -// We do NOT touch sessions outside our prefix — those belong to the -// user and are none of our business. +// Startup recovery compares saved references with the registry's managed live +// names. The prefix proves a session is managed by Agent Code, not that it is +// abandoned. Only a COMPLETE inventory can supply that second fact. A partial +// workspace can still recover its known terminals without authorizing deletion +// of terminals whose references may have been discarded by decoding (#898). +import { createHash, randomUUID } from 'node:crypto' + +import { parseWorkspaceFile } from '@main/storage/workspaceFile.js' +import type { ParsedWorkspaceFile } from '@main/storage/workspaceFile.js' import type { TmuxRegistry } from '@main/tmux/TmuxRegistry.js' +import { isSessionKind } from '@shared/types/providerKind.js' export type PersistedTerminalRef = { sessionId: string tmuxName: string } +type InventoryIssue = + | 'workspace_missing' + | 'workspace_read_failed' + | 'workspace_unreadable' + | 'invalid_windows_container' + | 'discarded_windows' + | 'invalid_sessions_container' + | 'invalid_session_metadata' + | 'invalid_terminal_reference' + | 'conflicting_terminal_reference' + +// Fixed categories and counts keep diagnostics bounded and avoid serializing +// workspace content, paths, or per-row parse errors into the startup journal. +type InventoryIssues = Partial> + +type TerminalInventory = { + kind: 'complete' | 'incomplete' | 'unknown' + references: PersistedTerminalRef[] + issues: InventoryIssues +} + export type RecoveryReport = { - /** tmuxName values that were alive and matched a persisted ref — - * caller should re-attach instead of respawning. */ + inventory: TerminalInventory['kind'] + inventoryIssues: InventoryIssues + /** Identifies the exact bytes inspected, including unreadable bytes. Null + * means the read failed; it must not look like the digest of an empty file. */ + inventoryDigest: string | null + /** Known live references, including ones retained by a partial decoder. */ recoverable: PersistedTerminalRef[] - /** Persisted sessionIds whose tmuxName was NOT alive — caller - * should treat these as fresh spawns. */ + /** Known references absent from the registry's live-session listing. */ lost: string[] - /** Alive Agent Code-owned sessions that were NOT in persisted state. - * P1 kills these silently; P2 will route them to the undo tray. */ + /** Unmatched managed names cleaned only after a complete inventory. */ orphans: string[] + /** Unmatched names left alive because absence of ownership is unproven. */ + preserved: string[] } -export async function reconcile( - registry: TmuxRegistry, - persisted: PersistedTerminalRef[], +type RecoveryRegistry = Pick + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function terminalInventory(parsed: ParsedWorkspaceFile): TerminalInventory { + if (parsed.kind === 'unreadable') { + return { kind: 'unknown', references: [], issues: { workspace_unreadable: 1 } } + } + + const issues: InventoryIssues = {} + const note = (issue: InventoryIssue, count = 1) => { issues[issue] = (issues[issue] ?? 0) + count } + if (parsed.completeness.kind === 'partial') { + if (parsed.completeness.invalidWindowsContainer) note('invalid_windows_container') + if (parsed.completeness.discardedWindows > 0) note('discarded_windows', parsed.completeness.discardedWindows) + } + + const references: PersistedTerminalRef[] = [] + const bySession = new Map>() + const byName = new Map() + for (const window of parsed.file.windows) { + // This is a resource projection, not another renderer ownership walker. + // The saved sessions map includes visible, detached, and buried metadata. + // Even a stale-looking row must protect its named process: layout repair + // does not establish that the process is safe to kill. + if (!isRecord(window.workspace) || !isRecord(window.workspace.sessions)) { + note('invalid_sessions_container') + continue + } + for (const [sessionId, meta] of Object.entries(window.workspace.sessions)) { + if (!sessionId || !isRecord(meta) || (meta.kind !== undefined && !isSessionKind(meta.kind))) { + note('invalid_session_metadata') + continue + } + // Missing kind is the documented legacy Claude shape. Missing tmuxName + // is valid for direct-PTY terminals; neither means a broken reference. + // A name on an unexpected kind, however, must not be silently discarded + // and then used as evidence that its live process has no owner. + if (meta.tmuxName === undefined) continue + if (meta.kind !== 'terminal' || typeof meta.tmuxName !== 'string' || meta.tmuxName.trim().length === 0) { + note('invalid_terminal_reference') + continue + } + const names = bySession.get(sessionId) ?? new Set() + if (names.has(meta.tmuxName)) continue // Same saved ref can appear in two window slices. + if (names.size > 0 || (byName.has(meta.tmuxName) && byName.get(meta.tmuxName) !== sessionId)) { + note('conflicting_terminal_reference') + } + names.add(meta.tmuxName) + bySession.set(sessionId, names) + byName.set(meta.tmuxName, sessionId) + references.push({ sessionId, tmuxName: meta.tmuxName }) + } + } + return { kind: Object.keys(issues).length === 0 ? 'complete' : 'incomplete', references, issues } +} + +/** + * The startup boundary owns reading and decoding together so restoration and + * cleanup cannot acquire independent interpretations of workspace.json again. + * Only read failures are translated here: a registry/termination failure must + * still reach the caller as a recovery failure, never as successful cleanup. + */ +export async function reconcileWorkspace( + registry: RecoveryRegistry, + readWorkspace: () => Promise, +): Promise { + let text: string + try { + text = await readWorkspace() + } catch (error) { + // ENOENT is normal on first install, but is not proof of orphanhood if a + // prior run's managed sessions survive a deleted/moved workspace file. + const issue = isRecord(error) && error.code === 'ENOENT' ? 'workspace_missing' : 'workspace_read_failed' + return reconcile(registry, { kind: 'unknown', references: [], issues: { [issue]: 1 } }) + } + // The journal needs to identify which file snapshot authorized cleanup even + // after a renderer autosave replaces that file. Counts alone cannot tell two + // different inventories apart. A digest carries that evidence without + // copying workspace paths, prompts, or terminal content into diagnostics. + const digest = createHash('sha256').update(text, 'utf8').digest('hex') + return reconcile(registry, terminalInventory(parseWorkspaceFile(text, randomUUID)), digest) +} + +async function reconcile( + registry: RecoveryRegistry, + inventory: TerminalInventory, + inventoryDigest: string | null = null, ): Promise { + const persisted = inventory.references + const evidence = { inventory: inventory.kind, inventoryIssues: inventory.issues, inventoryDigest } if (!registry.isAvailable()) { - // No tmux means no recovery is possible — every persisted ref - // is "lost" by definition (caller will treat as fresh spawn). - return { recoverable: [], lost: persisted.map(p => p.sessionId), orphans: [] } + return { ...evidence, recoverable: [], lost: persisted.map(p => p.sessionId), orphans: [], preserved: [] } } const aliveSessions = await registry.listManagedSessions() const aliveNames = new Set(aliveSessions.map(s => s.name)) const persistedNames = new Set(persisted.map(p => p.tmuxName)) - const recoverable = persisted.filter(p => aliveNames.has(p.tmuxName)) - const lost = persisted - .filter(p => !aliveNames.has(p.tmuxName)) - .map(p => p.sessionId) - const orphans = aliveSessions - .filter(s => !persistedNames.has(s.name)) - .map(s => s.name) - - // P1: silently kill orphans. They're stale Agent Code sessions from - // a previous run that failed to clean up. The registry's prefix - // guarantees these are ours to kill. + const lost = persisted.filter(p => !aliveNames.has(p.tmuxName)).map(p => p.sessionId) + const unmatched = [...aliveNames].filter(name => !persistedNames.has(name)) + + // Partial UI restoration is not a cleanup capability. Keep unmatched names + // explicitly preserved; calling them "orphans" would invite a downstream + // caller to delete them later. Only an explicit complete result, including + // a genuinely empty one, retains the existing orphan-cleanup policy. + // This is authority from this startup's file snapshot, not a durable repair + // journal. Preserving references through later workspace rewrites requires + // a separate persistence contract; an in-memory flag here cannot promise it. + const orphans = inventory.kind === 'complete' ? unmatched : [] + const preserved = inventory.kind === 'complete' ? [] : unmatched await Promise.all(orphans.map(name => registry.killSession(name))) - return { recoverable, lost, orphans } + return { ...evidence, recoverable, lost, orphans, preserved } } diff --git a/testing/fixtures/workspace-v2/2026-09-19-live-workspace.sanitized.json b/testing/fixtures/workspace-v2/2026-09-19-live-workspace.sanitized.json new file mode 100644 index 000000000..085c73501 --- /dev/null +++ b/testing/fixtures/workspace-v2/2026-09-19-live-workspace.sanitized.json @@ -0,0 +1,759 @@ +{ + "version": 2, + "windows": [ + { + "windowId": "8ba30627-5c41-4986-a325-b4ada30aa5f0", + "bounds": { + "x": 35, + "y": 31, + "width": 1400, + "height": 861 + }, + "displayId": 1, + "fullScreen": true, + "workspace": { + "tabs": [ + { + "id": "3bf27c7f-2e3a-4da1-a35a-e013ad86f937", + "title": "Title 1", + "focusedSessionId": "e5bf85b2-4c26-4b2a-a465-846289ec2e73", + "root": { + "type": "leaf", + "sessionId": "5cf66257-4284-40a7-868e-68ea84457063" + } + }, + { + "id": "e0224b91-da18-4b20-9cc0-da491569a6b5", + "title": "Title 2", + "focusedSessionId": "5906040e-f07c-4699-a153-d79647e07edb", + "root": { + "type": "leaf", + "sessionId": "575880c6-d447-49b8-aa9b-64705d70c287" + } + }, + { + "id": "d3a84a9d-2993-4423-9fa4-8c6a457b4e7b", + "title": "Title 3", + "focusedSessionId": "fc7d126c-08e6-41ae-bb1a-eeaba5482f93", + "root": { + "type": "leaf", + "sessionId": "9bb36de4-39a0-434a-b0b1-00f017d759bd" + } + } + ], + "activeTabId": "e0224b91-da18-4b20-9cc0-da491569a6b5", + "dispatchMode": { + "scope": "global", + "tiled": { + "lanes": [ + { + "selectedSessionId": "46179162-02ed-491d-97dd-6e291392b280" + }, + { + "selectedSessionId": "5906040e-f07c-4699-a153-d79647e07edb" + }, + { + "selectedSessionId": "e1f2ba36-affe-41de-babf-cd18264b23f2" + }, + { + "selectedSessionId": "1a8cf549-cb85-4e04-b00e-07bd81cb44a3" + }, + { + "selectedSessionId": "7327ced2-fb07-4b63-a357-50d3f94f8fb6" + }, + { + "selectedSessionId": "fc7d126c-08e6-41ae-bb1a-eeaba5482f93" + }, + { + "selectedSessionId": "e8c32320-d286-4315-a9d0-775931ad998e" + }, + { + "selectedSessionId": "4cf53ec5-c273-4b22-b4e6-d4b2cfd9cd86" + }, + { + "selectedSessionId": "f52c6345-1340-4974-b265-7aa62adcb628" + }, + { + "selectedSessionId": "e5bf85b2-4c26-4b2a-a465-846289ec2e73" + } + ], + "rows": [ + { + "length": 4, + "capChildren": false, + "indexFraction": 0.1, + "height": 0.4928831303208002 + }, + { + "length": 6, + "height": 0.5071168696791998, + "indexFraction": 0.1 + } + ], + "focusedLane": 1, + "laneWeights": [ + 0.1815063240813522, + 0.19383544136045075, + 0.19383544136045072, + 0.2, + 0.17082670442231684, + 0.17751420174047552, + 0.15464740575171249, + 0.16666666666666666, + 0.16666666666666666, + 0.16726432904956762 + ] + }, + "focusedSessionId": "1a8cf549-cb85-4e04-b00e-07bd81cb44a3" + }, + "sessions": { + "5cf66257-4284-40a7-868e-68ea84457063": { + "tldrIdentity": "ff07188d-ac46-4690-861b-175a42a4fdbb", + "cwd": "/fixture/project-1", + "kind": "claude", + "builtInMcpDomains": [ + "tldr", + "goal", + "orchestration", + "agent_transcripts", + "goal_loop" + ], + "builtInMcpOverrides": {}, + "agentNameId": "5cf66257-4284-40a7-868e-68ea84457063" + }, + "575880c6-d447-49b8-aa9b-64705d70c287": { + "tldrIdentity": "6fb1cb83-be8e-4def-a697-0c1568bccdf7", + "cwd": "/fixture/project-2", + "kind": "claude", + "builtInMcpDomains": [ + "tldr", + "goal", + "orchestration", + "agent_transcripts", + "goal_loop" + ], + "builtInMcpOverrides": {}, + "agentNameId": "575880c6-d447-49b8-aa9b-64705d70c287" + }, + "e6e19a29-f8b4-44da-bb9a-38fcfca2a314": { + "tldrIdentity": "8181bc5b-8eb8-4ee5-b2a0-85c8a9f17e39", + "cwd": "/fixture/project-1", + "kind": "opencode", + "providerRuntime": "terminal", + "providerSessionId": "ses_11e052c875fb4a9a85c25fe68823817c", + "providerSessionIdSource": "jsonl-entry", + "builtInMcpDomains": [ + "agent_transcripts", + "workflows", + "orchestration", + "tldr", + "goal", + "root_management" + ], + "builtInMcpOverrides": { + "root_management": true + }, + "agentNameId": "e6e19a29-f8b4-44da-bb9a-38fcfca2a314" + }, + "7327ced2-fb07-4b63-a357-50d3f94f8fb6": { + "tldrIdentity": "6f020926-c0cf-4ae4-a34c-14c38e7d9140", + "cwd": "/fixture/project-2", + "kind": "opencode", + "providerRuntime": "terminal", + "providerSessionId": "ses_059fa105f83e4f198c9eef50297feb0d", + "providerSessionIdSource": "jsonl-entry", + "builtInMcpDomains": [ + "tldr", + "goal", + "orchestration", + "agent_transcripts", + "workflows", + "goal_loop" + ], + "builtInMcpOverrides": {}, + "agentNameId": "7327ced2-fb07-4b63-a357-50d3f94f8fb6" + }, + "1d0db3d8-b277-4a8d-81b1-5269d76ed48a": { + "tldrIdentity": "1f45c69e-3ab7-4ba8-a236-2671dea748b3", + "cwd": "/fixture/project-2", + "kind": "opencode", + "providerRuntime": "terminal", + "providerSessionId": "ses_e174226937424a52aebfbc37341fb0ee", + "providerSessionIdSource": "jsonl-entry", + "builtInMcpDomains": [ + "tldr", + "goal", + "orchestration", + "agent_transcripts", + "workflows" + ], + "builtInMcpOverrides": {}, + "agentNameId": "1d0db3d8-b277-4a8d-81b1-5269d76ed48a" + }, + "a9c17d51-2ba2-4712-aa87-7cf7b8d24a36": { + "tldrIdentity": "e16b849e-456e-4c35-ab46-afb0131e0c39", + "cwd": "/fixture/project-2", + "kind": "claude", + "providerSessionId": "73d6ea25-a589-4e4f-a94a-e42aabe5db62", + "providerSessionIdSource": "jsonl-entry", + "builtInMcpDomains": [ + "tldr", + "goal", + "orchestration", + "agent_transcripts", + "goal_loop" + ], + "builtInMcpOverrides": {}, + "agentNameId": "a9c17d51-2ba2-4712-aa87-7cf7b8d24a36" + }, + "20c09242-4210-433b-b4cd-c0d31b47c507": { + "tldrIdentity": "a6761427-2f0a-45ef-8526-5b404db3cd32", + "cwd": "/fixture/project-2", + "kind": "opencode", + "providerRuntime": "terminal", + "providerSessionId": "ses_ceb81fe19a895e1c8dd4070fb508bffa", + "providerSessionIdSource": "jsonl-entry", + "builtInMcpDomains": [ + "tldr", + "goal", + "orchestration", + "agent_transcripts", + "workflows", + "goal_loop" + ], + "builtInMcpOverrides": {}, + "agentNameId": "20c09242-4210-433b-b4cd-c0d31b47c507" + }, + "9bb36de4-39a0-434a-b0b1-00f017d759bd": { + "tldrIdentity": "c95d99b5-ce11-4567-ae13-a848117e59d0", + "cwd": "/fixture/project-3", + "kind": "claude", + "builtInMcpDomains": [ + "tldr", + "goal", + "orchestration", + "agent_transcripts", + "goal_loop" + ], + "builtInMcpOverrides": {}, + "agentNameId": "9bb36de4-39a0-434a-b0b1-00f017d759bd" + }, + "1220cbcb-cabf-41b8-a37c-cebd8b506ce7": { + "tldrIdentity": "4a8a189b-d663-4190-a82c-d44f5893e86d", + "cwd": "/fixture/project-3", + "kind": "opencode", + "providerRuntime": "terminal", + "providerSessionId": "ses_a121d2d5434b4b6bb970ee5dfc170156", + "providerSessionIdSource": "jsonl-entry", + "builtInMcpDomains": [ + "agent_transcripts", + "workflows", + "orchestration", + "tldr", + "goal" + ], + "builtInMcpOverrides": {}, + "agentNameId": "1220cbcb-cabf-41b8-a37c-cebd8b506ce7" + }, + "6d6cac8c-fe3d-4f5e-82e3-740036b4aebd": { + "tldrIdentity": "09444f28-ee09-4666-82e7-27fd8a4557b9", + "cwd": "/fixture/project-2", + "kind": "opencode", + "providerRuntime": "terminal", + "providerSessionId": "ses_7e76f12489224b43abc3984fe2e5bda2", + "providerSessionIdSource": "jsonl-entry", + "builtInMcpDomains": [ + "tldr", + "goal", + "orchestration", + "agent_transcripts", + "workflows", + "root_management" + ], + "builtInMcpOverrides": { + "root_management": true + }, + "agentNameId": "6d6cac8c-fe3d-4f5e-82e3-740036b4aebd" + }, + "a15c8aa6-9d69-4907-ad51-71f7fbe5cc01": { + "cwd": "/fixture/project-2", + "kind": "terminal", + "tmuxName": "agentcode-b5f36ea3-4d90-4b32-866e-586bfd09b3c7", + "builtInMcpOverrides": {} + }, + "6579b6f4-77eb-4813-93dd-2e66a9c46789": { + "tldrIdentity": "d4b81a48-1657-4038-8b92-e49107ee22c2", + "cwd": "/fixture/project-2", + "kind": "opencode", + "providerRuntime": "terminal", + "providerSessionId": "ses_6840aca1a7c64d5eaa557982bb32284f", + "providerSessionIdSource": "jsonl-entry", + "builtInMcpDomains": [ + "tldr", + "goal", + "orchestration", + "agent_transcripts", + "workflows" + ], + "builtInMcpOverrides": {} + }, + "4d757102-4a13-47c2-9fe0-8a9359cd8323": { + "tldrIdentity": "7c218c21-e550-41b6-805e-3399481313fc", + "cwd": "/fixture/project-2", + "kind": "codex", + "builtInMcpDomains": [ + "tldr", + "goal", + "orchestration", + "agent_transcripts", + "workflows" + ], + "builtInMcpOverrides": {} + }, + "f52c6345-1340-4974-b265-7aa62adcb628": { + "tldrIdentity": "93ee4ddf-a91f-43e8-9979-357f33b75f13", + "cwd": "/fixture/project-2", + "kind": "opencode", + "providerRuntime": "terminal", + "providerSessionId": "ses_2a062349fa314992a0ee22dedc785b39", + "providerSessionIdSource": "jsonl-entry", + "builtInMcpDomains": [ + "tldr", + "goal", + "orchestration", + "agent_transcripts", + "workflows", + "goal_loop" + ], + "builtInMcpOverrides": {} + }, + "46179162-02ed-491d-97dd-6e291392b280": { + "tldrIdentity": "73eb7c1e-4c4c-46e2-907a-18890b8bf9d8", + "cwd": "/fixture/project-2", + "kind": "opencode", + "providerRuntime": "terminal", + "providerSessionId": "ses_a23039e0d977552598e1929e8eb2ddb3", + "providerSessionIdSource": "jsonl-entry", + "builtInMcpDomains": [ + "tldr", + "goal", + "orchestration", + "agent_transcripts", + "workflows", + "goal_loop" + ], + "builtInMcpOverrides": {}, + "agentNameId": "1e4499d9-a803-40c0-ba4b-af4e77fbc488" + }, + "4cf53ec5-c273-4b22-b4e6-d4b2cfd9cd86": { + "tldrIdentity": "466d7674-a592-4831-bf94-498bae8469bb", + "cwd": "/fixture/project-2", + "kind": "opencode", + "providerRuntime": "terminal", + "providerSessionId": "ses_df852486a67055db97fd3c581105814d", + "providerSessionIdSource": "jsonl-entry", + "builtInMcpDomains": [ + "tldr", + "goal", + "orchestration", + "agent_transcripts", + "workflows", + "goal_loop" + ], + "builtInMcpOverrides": {} + }, + "e8c32320-d286-4315-a9d0-775931ad998e": { + "tldrIdentity": "0409fa5d-fd48-4ed8-8d7a-2f981c932d91", + "cwd": "/fixture/project-2", + "kind": "opencode", + "providerRuntime": "terminal", + "providerSessionId": "ses_04f8f80e5ee35d879acc0b4850d56bc1", + "providerSessionIdSource": "jsonl-entry", + "builtInMcpDomains": [ + "tldr", + "goal", + "orchestration", + "agent_transcripts", + "workflows", + "goal_loop" + ], + "builtInMcpOverrides": {} + }, + "cddf53b0-0a1f-4677-bf24-0b90a17fb813": { + "tldrIdentity": "618cda65-5980-4915-92ca-43de3426761f", + "cwd": "/fixture/project-2", + "kind": "claude", + "builtInMcpDomains": [ + "tldr", + "goal", + "orchestration", + "agent_transcripts", + "goal_loop" + ], + "builtInMcpOverrides": {}, + "title": "Title 4", + "orchestrationRole": "reviewer", + "providerSessionId": "457a54df-4bc8-4e70-870e-c580216e3243", + "providerSessionIdSource": "jsonl-entry", + "orchestrationBootstrapPromptDelivered": true, + "orchestrationParentId": "46179162-02ed-491d-97dd-6e291392b280", + "orchestrationRootId": "46179162-02ed-491d-97dd-6e291392b280" + }, + "e1f2ba36-affe-41de-babf-cd18264b23f2": { + "tldrIdentity": "718837e5-dfc9-4e90-bdff-f4f6ed338de0", + "cwd": "/fixture/project-2", + "kind": "opencode", + "providerRuntime": "terminal", + "providerSessionId": "ses_0ff1ddce360b4c8fa3dc3df4a52bf415", + "providerSessionIdSource": "jsonl-entry", + "builtInMcpDomains": [ + "tldr", + "goal", + "orchestration", + "agent_transcripts", + "workflows", + "goal_loop" + ], + "builtInMcpOverrides": {} + }, + "c06e1822-ffd5-4d3c-870a-c3c1c084dff7": { + "tldrIdentity": "8011357b-8507-4758-8b3b-d992996ed88b", + "cwd": "/fixture/project-2", + "kind": "claude", + "builtInMcpDomains": [ + "tldr", + "goal", + "orchestration", + "agent_transcripts", + "goal_loop" + ], + "builtInMcpOverrides": {}, + "title": "Title 5", + "orchestrationRole": "reviewer", + "providerSessionId": "31eb0f61-f240-4193-9836-19c10a51634e", + "providerSessionIdSource": "jsonl-entry", + "orchestrationBootstrapPromptDelivered": true, + "orchestrationParentId": "46179162-02ed-491d-97dd-6e291392b280", + "orchestrationRootId": "46179162-02ed-491d-97dd-6e291392b280" + }, + "c214f5c1-3fe4-4139-9567-89ba5c657932": { + "tldrIdentity": "5e9a5372-4591-4018-ace0-c8ca7ca2ccef", + "cwd": "/fixture/project-2", + "kind": "codex", + "providerSessionId": "9a968484-2925-4d7b-9726-84ce8278b7d2", + "providerSessionIdSource": "resume-request", + "builtInMcpDomains": [ + "tldr", + "goal", + "orchestration", + "agent_transcripts", + "workflows", + "goal_loop" + ], + "builtInMcpOverrides": {} + }, + "e5bf85b2-4c26-4b2a-a465-846289ec2e73": { + "tldrIdentity": "87884004-8419-44c5-9c82-c437c793d66a", + "cwd": "/fixture/project-1", + "kind": "opencode", + "providerRuntime": "terminal", + "providerSessionId": "ses_670556623cb9422fbb5e75f2cae493d4", + "providerSessionIdSource": "jsonl-entry", + "builtInMcpDomains": [ + "tldr", + "goal", + "orchestration", + "agent_transcripts", + "workflows", + "goal_loop" + ], + "builtInMcpOverrides": {} + }, + "1ff7c63e-8238-4618-beeb-abf445b19e33": { + "tldrIdentity": "cb20661e-3fbf-4eff-9292-bd4729c2c379", + "cwd": "/fixture/project-2", + "kind": "claude", + "builtInMcpDomains": [ + "tldr", + "goal", + "orchestration", + "agent_transcripts", + "goal_loop" + ], + "builtInMcpOverrides": {}, + "title": "Title 6", + "orchestrationRole": "reviewer", + "providerSessionId": "79fd7bf5-3e71-4bb7-ad0a-0b81eda6bdb6", + "providerSessionIdSource": "jsonl-entry", + "orchestrationBootstrapPromptDelivered": true, + "orchestrationParentId": "46179162-02ed-491d-97dd-6e291392b280", + "orchestrationRootId": "46179162-02ed-491d-97dd-6e291392b280" + }, + "fc7d126c-08e6-41ae-bb1a-eeaba5482f93": { + "tldrIdentity": "80b94b62-6acc-422e-afba-9a41189d4cce", + "cwd": "/fixture/project-3", + "kind": "claude", + "providerSessionId": "a6bdcee3-3c80-4ebc-bd59-a46c07db6028", + "providerSessionIdSource": "jsonl-entry", + "builtInMcpDomains": [ + "tldr", + "goal", + "orchestration", + "agent_transcripts", + "goal_loop" + ], + "builtInMcpOverrides": {}, + "agentNameId": "b311a9ed-62c0-4b8e-82be-b945cc46cae3" + }, + "f6413f85-61a8-4aad-b146-caeaf304ba0e": { + "tldrIdentity": "9c1103eb-91e5-4455-ac72-a0e9e99e9109", + "cwd": "/fixture/project-2", + "kind": "claude", + "providerSessionId": "731fba37-3659-4e3b-844d-c0fd292e3cc0", + "providerSessionIdSource": "jsonl-entry", + "builtInMcpDomains": [ + "tldr", + "goal", + "orchestration", + "agent_transcripts", + "goal_loop" + ], + "builtInMcpOverrides": {}, + "agentNameId": "2c690b9e-d218-4543-984d-02c268a043ca" + }, + "1a8cf549-cb85-4e04-b00e-07bd81cb44a3": { + "tldrIdentity": "d16ffc71-83b8-48ff-a42e-5a150ba11a4b", + "cwd": "/fixture/project-2", + "kind": "grok", + "providerSessionId": "3dd6af5a-8bee-4092-81ea-f3820438aaf5", + "providerSessionIdSource": "jsonl-entry", + "builtInMcpDomains": [ + "tldr", + "goal", + "orchestration", + "agent_transcripts", + "workflows", + "goal_loop" + ], + "builtInMcpOverrides": {}, + "agentViewModeOverride": "terminal" + }, + "5906040e-f07c-4699-a153-d79647e07edb": { + "tldrIdentity": "2776c698-22a4-41ee-bed2-26cd0ac41e20", + "cwd": "/fixture/project-2", + "kind": "claude", + "providerSessionId": "83a02301-2e9a-42a6-98cb-38cffc008e25", + "providerSessionIdSource": "jsonl-entry", + "builtInMcpDomains": [ + "tldr", + "goal", + "orchestration", + "agent_transcripts", + "goal_loop", + "root_management" + ], + "builtInMcpOverrides": { + "root_management": true + } + } + }, + "detachedSessions": { + "e6e19a29-f8b4-44da-bb9a-38fcfca2a314": { + "sessionId": "e6e19a29-f8b4-44da-bb9a-38fcfca2a314", + "surface": "dispatch", + "projectTabId": "3bf27c7f-2e3a-4da1-a35a-e013ad86f937", + "projectTabTitle": "Project 1", + "projectTabIndex": 0, + "detachedAt": 1789450871302 + }, + "7327ced2-fb07-4b63-a357-50d3f94f8fb6": { + "sessionId": "7327ced2-fb07-4b63-a357-50d3f94f8fb6", + "surface": "dispatch", + "projectTabId": "e0224b91-da18-4b20-9cc0-da491569a6b5", + "projectTabTitle": "Project 2", + "projectTabIndex": 1, + "detachedAt": 1789605758748 + }, + "1d0db3d8-b277-4a8d-81b1-5269d76ed48a": { + "sessionId": "1d0db3d8-b277-4a8d-81b1-5269d76ed48a", + "surface": "dispatch", + "projectTabId": "e0224b91-da18-4b20-9cc0-da491569a6b5", + "projectTabTitle": "Project 2", + "projectTabIndex": 1, + "detachedAt": 1789606454470 + }, + "a9c17d51-2ba2-4712-aa87-7cf7b8d24a36": { + "sessionId": "a9c17d51-2ba2-4712-aa87-7cf7b8d24a36", + "surface": "dispatch", + "projectTabId": "e0224b91-da18-4b20-9cc0-da491569a6b5", + "projectTabTitle": "Project 2", + "projectTabIndex": 1, + "detachedAt": 1789676671282 + }, + "20c09242-4210-433b-b4cd-c0d31b47c507": { + "sessionId": "20c09242-4210-433b-b4cd-c0d31b47c507", + "surface": "dispatch", + "projectTabId": "e0224b91-da18-4b20-9cc0-da491569a6b5", + "projectTabTitle": "Project 2", + "projectTabIndex": 1, + "detachedAt": 1789677019212 + }, + "1220cbcb-cabf-41b8-a37c-cebd8b506ce7": { + "sessionId": "1220cbcb-cabf-41b8-a37c-cebd8b506ce7", + "surface": "dispatch", + "projectTabId": "d3a84a9d-2993-4423-9fa4-8c6a457b4e7b", + "projectTabTitle": "Project 3", + "projectTabIndex": 2, + "detachedAt": 1789679456635 + }, + "6d6cac8c-fe3d-4f5e-82e3-740036b4aebd": { + "sessionId": "6d6cac8c-fe3d-4f5e-82e3-740036b4aebd", + "surface": "dispatch", + "projectTabId": "e0224b91-da18-4b20-9cc0-da491569a6b5", + "projectTabTitle": "Project 2", + "projectTabIndex": 1, + "detachedAt": 1789600692720 + }, + "a15c8aa6-9d69-4907-ad51-71f7fbe5cc01": { + "sessionId": "a15c8aa6-9d69-4907-ad51-71f7fbe5cc01", + "surface": "dispatch", + "projectTabId": "e0224b91-da18-4b20-9cc0-da491569a6b5", + "projectTabTitle": "Project 2", + "projectTabIndex": 1, + "detachedAt": 1789693969704 + }, + "6579b6f4-77eb-4813-93dd-2e66a9c46789": { + "sessionId": "6579b6f4-77eb-4813-93dd-2e66a9c46789", + "surface": "dispatch", + "projectTabId": "e0224b91-da18-4b20-9cc0-da491569a6b5", + "projectTabTitle": "Project 2", + "projectTabIndex": 1, + "detachedAt": 1789695773526 + }, + "4d757102-4a13-47c2-9fe0-8a9359cd8323": { + "sessionId": "4d757102-4a13-47c2-9fe0-8a9359cd8323", + "surface": "dispatch", + "projectTabId": "e0224b91-da18-4b20-9cc0-da491569a6b5", + "projectTabTitle": "Project 2", + "projectTabIndex": 1, + "detachedAt": 1789763911968 + }, + "f52c6345-1340-4974-b265-7aa62adcb628": { + "sessionId": "f52c6345-1340-4974-b265-7aa62adcb628", + "surface": "dispatch", + "projectTabId": "e0224b91-da18-4b20-9cc0-da491569a6b5", + "projectTabTitle": "Project 2", + "projectTabIndex": 1, + "detachedAt": 1789767010622 + }, + "46179162-02ed-491d-97dd-6e291392b280": { + "sessionId": "46179162-02ed-491d-97dd-6e291392b280", + "surface": "dispatch", + "projectTabId": "e0224b91-da18-4b20-9cc0-da491569a6b5", + "projectTabTitle": "Project 2", + "projectTabIndex": 1, + "detachedAt": 1789677047173 + }, + "4cf53ec5-c273-4b22-b4e6-d4b2cfd9cd86": { + "sessionId": "4cf53ec5-c273-4b22-b4e6-d4b2cfd9cd86", + "surface": "dispatch", + "projectTabId": "e0224b91-da18-4b20-9cc0-da491569a6b5", + "projectTabTitle": "Project 2", + "projectTabIndex": 1, + "detachedAt": 1789695786492 + }, + "e8c32320-d286-4315-a9d0-775931ad998e": { + "sessionId": "e8c32320-d286-4315-a9d0-775931ad998e", + "surface": "dispatch", + "projectTabId": "e0224b91-da18-4b20-9cc0-da491569a6b5", + "projectTabTitle": "Project 2", + "projectTabIndex": 1, + "detachedAt": 1789763520414 + }, + "cddf53b0-0a1f-4677-bf24-0b90a17fb813": { + "sessionId": "cddf53b0-0a1f-4677-bf24-0b90a17fb813", + "surface": "dispatch", + "projectTabId": "e0224b91-da18-4b20-9cc0-da491569a6b5", + "projectTabTitle": "Project 2", + "projectTabIndex": 1, + "detachedAt": 1789781474390 + }, + "e1f2ba36-affe-41de-babf-cd18264b23f2": { + "sessionId": "e1f2ba36-affe-41de-babf-cd18264b23f2", + "surface": "dispatch", + "projectTabId": "e0224b91-da18-4b20-9cc0-da491569a6b5", + "projectTabTitle": "Project 2", + "projectTabIndex": 1, + "detachedAt": 1789782673868 + }, + "c06e1822-ffd5-4d3c-870a-c3c1c084dff7": { + "sessionId": "c06e1822-ffd5-4d3c-870a-c3c1c084dff7", + "surface": "dispatch", + "projectTabId": "e0224b91-da18-4b20-9cc0-da491569a6b5", + "projectTabTitle": "Project 2", + "projectTabIndex": 1, + "detachedAt": 1789782938786 + }, + "c214f5c1-3fe4-4139-9567-89ba5c657932": { + "sessionId": "c214f5c1-3fe4-4139-9567-89ba5c657932", + "surface": "dispatch", + "projectTabId": "e0224b91-da18-4b20-9cc0-da491569a6b5", + "projectTabTitle": "Project 2", + "projectTabIndex": 1, + "detachedAt": 1789783774180 + }, + "e5bf85b2-4c26-4b2a-a465-846289ec2e73": { + "sessionId": "e5bf85b2-4c26-4b2a-a465-846289ec2e73", + "surface": "dispatch", + "projectTabId": "3bf27c7f-2e3a-4da1-a35a-e013ad86f937", + "projectTabTitle": "Project 1", + "projectTabIndex": 0, + "detachedAt": 1789784330981 + }, + "1ff7c63e-8238-4618-beeb-abf445b19e33": { + "sessionId": "1ff7c63e-8238-4618-beeb-abf445b19e33", + "surface": "dispatch", + "projectTabId": "e0224b91-da18-4b20-9cc0-da491569a6b5", + "projectTabTitle": "Project 2", + "projectTabIndex": 1, + "detachedAt": 1789785703603 + }, + "fc7d126c-08e6-41ae-bb1a-eeaba5482f93": { + "sessionId": "fc7d126c-08e6-41ae-bb1a-eeaba5482f93", + "surface": "dispatch", + "projectTabId": "d3a84a9d-2993-4423-9fa4-8c6a457b4e7b", + "projectTabTitle": "Project 3", + "projectTabIndex": 2, + "detachedAt": 1789679477821 + }, + "f6413f85-61a8-4aad-b146-caeaf304ba0e": { + "sessionId": "f6413f85-61a8-4aad-b146-caeaf304ba0e", + "surface": "dispatch", + "projectTabId": "e0224b91-da18-4b20-9cc0-da491569a6b5", + "projectTabTitle": "Project 2", + "projectTabIndex": 1, + "detachedAt": 1789686086992 + }, + "1a8cf549-cb85-4e04-b00e-07bd81cb44a3": { + "sessionId": "1a8cf549-cb85-4e04-b00e-07bd81cb44a3", + "surface": "dispatch", + "projectTabId": "e0224b91-da18-4b20-9cc0-da491569a6b5", + "projectTabTitle": "Project 2", + "projectTabIndex": 1, + "detachedAt": 1789793748793 + }, + "5906040e-f07c-4699-a153-d79647e07edb": { + "sessionId": "5906040e-f07c-4699-a153-d79647e07edb", + "surface": "dispatch", + "projectTabId": "e0224b91-da18-4b20-9cc0-da491569a6b5", + "projectTabTitle": "Project 2", + "projectTabIndex": 1, + "detachedAt": 1789782517126 + } + }, + "buried": [], + "tileTabs": null + } + } + ] +} \ No newline at end of file diff --git a/testing/fixtures/workspace-v2/README.md b/testing/fixtures/workspace-v2/README.md new file mode 100644 index 000000000..12cff03a9 --- /dev/null +++ b/testing/fixtures/workspace-v2/README.md @@ -0,0 +1,27 @@ +# workspace-v2 fixtures + +## `2026-09-19-live-workspace.sanitized.json` + +This is a real `~/.config/agent-code/workspace.json`, persisted by the running +app on 2026-09-19. It holds one window and 27 sessions: 10 claude, 13 opencode, +2 codex, 1 grok and 1 terminal with a live `tmuxName`. It is the real v2 +envelope (`{ version: 2, windows: [...] }`) that #898's startup reader +misread. + +**Sanitization.** The structure and every value are unchanged, except for +three private string fields. Each is replaced through a stable one-to-one +placeholder map, so equal values stay equal and distinct values stay distinct: + +| Field | Replaced with | +|---|---| +| `cwd` | `/fixture/project-N` | +| `title` | `Title N` | +| `projectTabTitle` | `Project N` | + +IDs, `tmuxName`, kinds and layout are untouched: reconciliation matches on +exactly these. After sanitizing, a scan for `/Users/`, the account name, +`Desktop` and `Development` finds nothing. + +To re-record, run the same walk over a live `workspace.json`: every +string-valued `cwd`, `title` or `projectTabTitle` key goes through a stable +placeholder map. Check the output the same way before committing it.