From c2b8fe74ed1e886e69ff0d8d6f5a0e5b5152686e Mon Sep 17 00:00:00 2001 From: Si Hyeong Lee Date: Thu, 22 Oct 2026 23:56:09 -0700 Subject: [PATCH] 1.45.2: an update no longer doubles the sessions running on a paired PC After an update relaunch, every session on the other PC came back twice: the running one (adopted from that PC's announcement) and a new, empty one under the same name. Measured 7 -> 14 -> 29 sessions on SIHYEONG-4080 across two consecutive updates. autoRestoreAfterUpdate restored REMOTE tiles too, and removed them from the saved list first. While it walked them one by one, the other PC announced its running sessions and syncMachineSessions adopted each as a tile. When a remote entry's turn came, its conversation was already a live tile here, so resolveRestoreTarget read it as "open in another tile" and started a fresh conversation on that PC - once per saved remote entry, per update. - takeUpdateRestoreCandidates: an update relaunch restores this PC's own tiles only; a remote tile stays saved and comes back by adoption with its tile id, label and pin (the rule plain launches already followed). - restoreSession: when the entry's conversation is already a live tile here, the restore is done - nothing is started. Checked before the remote call and again after the awaits, where an adoption can land. Logged as "already open here as ; nothing started". - liveIds/reserved are computed right before resolveRestoreTarget, not at the top of the function. - Drop the now-unused cockpit.restore_machine_offline_n locale key. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_016NLGwiBY5uEivJqERQzcoY --- README.md | 2 +- docs/releases/1.45.2.md | 13 +++++++ package-lock.json | 4 +-- package.json | 2 +- src/renderer/cockpitView.ts | 59 +++++++++++++++++++++---------- src/renderer/locales/en.json | 1 - src/renderer/locales/ja.json | 1 - src/renderer/locales/ko.json | 1 - src/renderer/locales/zh.json | 1 - src/shared/cockpitPersist.test.ts | 20 ++++++++++- src/shared/cockpitPersist.ts | 19 ++++++++++ 11 files changed, 95 insertions(+), 28 deletions(-) create mode 100644 docs/releases/1.45.2.md diff --git a/README.md b/README.md index 331adf9..82878f9 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ See what needs attention, resume the right conversation, and work on a paired PC -> **1.45.1** stops offering Claude background sessions whose folder was deleted, and says so plainly when a session's folder is gone ([release notes](docs/releases/1.45.1.md)). 1.45.0 sends any file to a session on another PC — any kind, up to 4 GB, in pieces with its progress shown ([release notes](docs/releases/1.45.0.md)). 1.44.1 fixes the "Assertion failed: remove_pty_baton" dialog (and the occasional hang or crash) when quitting or running Update all with several sessions open ([release notes](docs/releases/1.44.1.md)). 1.44.0 lists Claude Code's own background sessions (`claude --bg`, the agent view) in the sidebar, marks the ones waiting for you, and opens one in a tile by attaching to it ([release notes](docs/releases/1.44.0.md)). 1.43.0 runs parallel sessions in separate workspaces — each in its own git worktree and branch, so two agents never edit the same files — with its changes in the header and a clean-up that warns before unmerged work is lost ([release notes](docs/releases/1.43.0.md)). 1.42.0 shows what changed — a What's new window after each update, with Show me, and NEW dots on new buttons until you try them — and, as an experimental setting on Windows, lets Claude Code report its own state (working, needs approval, asking, done) instead of DevDeck reading the screen ([release notes](docs/releases/1.42.0.md)). 1.41.0 adds quick replies (one click or Ctrl+Shift+1…9 for the phrases you keep typing), a session timeline of your prompts with their times, replies and images, red boxes and arrows drawn on a screenshot and sent straight back to the session, and text, log and PDF attachments for sessions on another PC ([release notes](docs/releases/1.41.0.md)). 1.40.0 ties each screenshot to the terminal row it appeared on — hover a highlighted `[Image]` for a thumbnail, click for the full view, and jump from the viewer back to the row — says what led to each image, previews new screenshots, copies images, and adds Ctrl+↑/↓ between your prompts ([release notes](docs/releases/1.40.0.md)). 1.39.0 shows the screenshots and images a Claude session worked with — Claude Desktop shows them inline; DevDeck puts them in a strip beside the terminal, also for sessions on another PC — adds 📎 / drag-and-drop attachments (images travel to a remote PC), **Update all** for Claude Code on Windows, staggered session restores that no longer stall a PC, and ends the daily false "Login expired" ([release notes](docs/releases/1.39.0.md)). 1.38.0 made closing a session reliable across paired PCs ([notes](docs/releases/1.38.0.md)). See [the maintenance review](docs/quality-review-2026-09-08.md) and [session synchronization review](docs/session-sync-review-2026-09-08.md). +> **1.45.2** stops an update from doubling the sessions on a paired PC — the relaunch no longer restores remote tiles itself, it adopts them from that PC, and no restore starts a second conversation for one that is already open here ([release notes](docs/releases/1.45.2.md)). 1.45.1 stops offering Claude background sessions whose folder was deleted, and says so plainly when a session's folder is gone ([release notes](docs/releases/1.45.1.md)). 1.45.0 sends any file to a session on another PC — any kind, up to 4 GB, in pieces with its progress shown ([release notes](docs/releases/1.45.0.md)). 1.44.1 fixes the "Assertion failed: remove_pty_baton" dialog (and the occasional hang or crash) when quitting or running Update all with several sessions open ([release notes](docs/releases/1.44.1.md)). 1.44.0 lists Claude Code's own background sessions (`claude --bg`, the agent view) in the sidebar, marks the ones waiting for you, and opens one in a tile by attaching to it ([release notes](docs/releases/1.44.0.md)). 1.43.0 runs parallel sessions in separate workspaces — each in its own git worktree and branch, so two agents never edit the same files — with its changes in the header and a clean-up that warns before unmerged work is lost ([release notes](docs/releases/1.43.0.md)). 1.42.0 shows what changed — a What's new window after each update, with Show me, and NEW dots on new buttons until you try them — and, as an experimental setting on Windows, lets Claude Code report its own state (working, needs approval, asking, done) instead of DevDeck reading the screen ([release notes](docs/releases/1.42.0.md)). 1.41.0 adds quick replies (one click or Ctrl+Shift+1…9 for the phrases you keep typing), a session timeline of your prompts with their times, replies and images, red boxes and arrows drawn on a screenshot and sent straight back to the session, and text, log and PDF attachments for sessions on another PC ([release notes](docs/releases/1.41.0.md)). 1.40.0 ties each screenshot to the terminal row it appeared on — hover a highlighted `[Image]` for a thumbnail, click for the full view, and jump from the viewer back to the row — says what led to each image, previews new screenshots, copies images, and adds Ctrl+↑/↓ between your prompts ([release notes](docs/releases/1.40.0.md)). 1.39.0 shows the screenshots and images a Claude session worked with — Claude Desktop shows them inline; DevDeck puts them in a strip beside the terminal, also for sessions on another PC — adds 📎 / drag-and-drop attachments (images travel to a remote PC), **Update all** for Claude Code on Windows, staggered session restores that no longer stall a PC, and ends the daily false "Login expired" ([release notes](docs/releases/1.39.0.md)). 1.38.0 made closing a session reliable across paired PCs ([notes](docs/releases/1.38.0.md)). See [the maintenance review](docs/quality-review-2026-09-08.md) and [session synchronization review](docs/session-sync-review-2026-09-08.md).
DevDeck project overview with repository and session navigation diff --git a/docs/releases/1.45.2.md b/docs/releases/1.45.2.md new file mode 100644 index 0000000..7180397 --- /dev/null +++ b/docs/releases/1.45.2.md @@ -0,0 +1,13 @@ +# DevDeck 1.45.2 + +## An update no longer doubles the sessions running on a paired PC + +After **Update all** (or any update relaunch), every session on the other PC came back twice: the one that was already running there, and a new, empty one under the same name. Each update doubled them — measured 7 → 14 → 29 sessions running on one machine across two updates, every extra one a whole Claude process. + +- **Cause:** two things reopened the same saved tile at once. When this PC relaunched after the update, the other PC — which had kept running — announced its sessions, and DevDeck adopted each one as a tile. At the same time the update relaunch walked its list of saved tiles and restored them one by one, including the ones on the other PC. By the time a remote tile's turn came, its conversation was already a tile here (the adopted one), and the restore read that as "this conversation is open in another tile, so start a fresh one" — a new conversation on the other PC, carrying the same name and label. +- **Fix:** an update relaunch restores only this PC's own tiles. A tile on another PC is left as a saved entry and comes back by adoption the moment that PC announces it, keeping its label and pin. If that PC is updating too, it restores its own tiles on its launch and announces them — same result, no duplicates. +- **Also:** any restore — a click on a previous session, Restore all, the launch restore — now stops when the conversation is already a live tile here, instead of starting a second one. The log says so: `already open here as ; nothing started`. + +## Cleaning up what earlier versions left behind + +The duplicates created before this fix are still running on the other PC. They are the empty sessions with no history. In the cockpit sidebar, Ctrl/Shift-click them and close them together. Each closed one frees a Claude process on that PC. diff --git a/package-lock.json b/package-lock.json index 6cdb590..bc765d8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "devdeck", - "version": "1.45.1", + "version": "1.45.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "devdeck", - "version": "1.45.1", + "version": "1.45.2", "license": "MIT", "dependencies": { "@fontsource-variable/geist": "5.3.0", diff --git a/package.json b/package.json index 5b22270..c08bf90 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "devdeck", - "version": "1.45.1", + "version": "1.45.2", "description": "Project command deck — at-a-glance state + Claude Code, Codex, and Antigravity resume", "main": "dist/main/main.js", "type": "commonjs", diff --git a/src/renderer/cockpitView.ts b/src/renderer/cockpitView.ts index 5bbbe4b..b46eb74 100644 --- a/src/renderer/cockpitView.ts +++ b/src/renderer/cockpitView.ts @@ -13,7 +13,7 @@ import { formatDuration } from '../shared/usage'; import { decideKeyAction, selectionCellLength } from '../shared/terminalKeys'; import { unwrapCopiedUrl } from '../shared/urlCopy'; import { findUrlLinks, findFilePathLinks, type BufferRow } from '../shared/linkWrap'; -import { cockpitNavigationId, cockpitNavigationIdForRuntime, createCockpitTileId, legacyCockpitNavigationId, persistedSessionKey, partitionLaunchRestore, takeLaunchRestoreCandidates, removeAutoRestoreMatches, sanitizePersistedList, resolveRestoreTarget, adoptRestorableMatch, type PersistedSession } from '../shared/cockpitPersist'; +import { cockpitNavigationId, cockpitNavigationIdForRuntime, createCockpitTileId, legacyCockpitNavigationId, persistedSessionKey, partitionLaunchRestore, takeLaunchRestoreCandidates, takeUpdateRestoreCandidates, removeAutoRestoreMatches, sanitizePersistedList, resolveRestoreTarget, adoptRestorableMatch, type PersistedSession } from '../shared/cockpitPersist'; import { OPEN_FOLDER_MISSING, toAgentId, type AgentId, type OpenMode, type StaleLevel } from '../shared/types'; import { createProviderLogo, providerName } from './providerLogo'; import { tr, currentLang } from './i18n-runtime'; @@ -332,20 +332,14 @@ export function mountCockpit(): void { * latest conversation (via restoreSession). They're removed from the "Previous" list first so they * aren't shown as restorable AND opened. Sequential to avoid a simultaneous PTY burst. */ async function autoRestoreAfterUpdate(pending: PersistedSession[]): Promise { - // Entries on a machine that is not reachable right now stay saved — they come back by adoption the - // moment that machine connects and is found running them, or by a click. Asking now would only - // produce one "offline" toast per entry; one per machine is enough. - const offlineByMachine = new Map(); - const now = pending.filter((entry) => { - const machineId = entry.machineId ?? LOCAL_MACHINE_ID; - if (machineId === LOCAL_MACHINE_ID || machineState(machineId) === 'connected') return true; - offlineByMachine.set(machineId, (offlineByMachine.get(machineId) ?? 0) + 1); - return false; - }); - for (const [machineId, n] of offlineByMachine) toast(tr('cockpit.restore_machine_offline_n', { n: String(n), machine: machineName(machineId) })); - restorable = removeAutoRestoreMatches(restorable, now); + // Only this machine's tiles (takeUpdateRestoreCandidates says why). A remote tile stays a saved + // entry and comes back by adoption when its machine announces it — restoring it from here too raced + // that adoption and started a second conversation on that machine, once per entry, per update. + const { own, remote } = takeUpdateRestoreCandidates(pending); + if (remote.length) window.devdeck.logDiagnostic(`update restore: ${own.length} tile(s) of this pc restored here; ${remote.length} remote tile(s) left to adoption`, 'info', 'restore'); + restorable = removeAutoRestoreMatches(restorable, own); renderList(); - await restoreInTurn(now); + await restoreInTurn(own); } const START_QUIET_MS = 1500; @@ -2857,6 +2851,19 @@ async function deliverPendingCloses(machineId: string): Promise { // tile, and now that an unopenable conversation comes back fresh, as an empty one. const restoring = new Set(); +/** + * The live tile already holding a saved entry's conversation on its machine, if any. Typically an + * adoption that landed while the entry waited its turn in a restore loop: the machine announced the + * terminal, syncMachineSessions built a tile for it. The restore is then already DONE — opening anything + * now would be a second conversation under the same name on that machine (which is how two consecutive + * updates took the paired machine from 7 to 29 running sessions). + */ +function liveTileHolding(entry: PersistedSession): Live | undefined { + if (!entry.sessionId) return undefined; + return [...live.values()].find((l) => l.openedSessionId === entry.sessionId && l.session.status !== 'exited' + && sameScope({ machineId: l.machineId, projectPath: l.session.projectPath, agentId: l.session.agentId }, entry)); +} + /** Bring a previous session back to life via its resume command — under the SAME provider it was * opened with, whatever the globally selected agent is now (a Claude conversation must never be * handed to `codex`). Unrecognized legacy ids fall back to the active agent, as they always did. */ @@ -2871,14 +2878,21 @@ async function restoreSession(entry: PersistedSession): Promise { // a FRESH one under the same name. Never a substitute: this tile carries the user's own label and // pin, and handing it someone else's conversation is what made a renamed tile come back showing // unrelated work (and silently ate the entry that really owned that conversation). - const liveIds = new Set(liveSessionsForPersist().filter(l => sameScope(l, entry)).map(l => l.sessionId).filter((x): x is string => !!x)); - // Conversations the other not-yet-restored entries are waiting for — an id-less entry must not - // take one of those out from under them. - const reserved = new Set(restorable.filter(r => sameScope(r, entry)).map((r) => r.sessionId).filter((x): x is string => !!x)); // A saved entry names a project by PATH, and the same path exists on both machines — so the // conversation list has to come from the machine the tile actually ran on. Reading it locally // would resolve the tile against unrelated work that happens to live at the same path. const machineId = entry.machineId ?? LOCAL_MACHINE_ID; + const where = machineId === LOCAL_MACHINE_ID ? 'this pc' : machineName(machineId); + // Already a live tile here (adopted from its machine's announcement while this entry waited its + // turn): the restore is done. The entry is consumed — its pin carries over — and nothing starts. + // Checked again below after each await, because that adoption can land at any of them. + const merged = (held: Live, when: string): void => { + if (entry.pinned && !held.pinned) held.pinned = true; + persist(); renderList(); + window.devdeck.logDiagnostic(`restore ${entry.label || entry.name} on ${where}: saved=${entry.sessionId} -> already open here as ${held.session.id} (${when}); nothing started`, 'info', 'restore'); + }; + const heldNow = liveTileHolding(entry); + if (heldNow) { merged(heldNow, 'before asking'); return; } if (machineId !== LOCAL_MACHINE_ID && machineState(machineId) !== 'connected') { // Its machine is not reachable. Leave the entry saved and say so, rather than opening a local // terminal in a path that means something different here. @@ -2915,13 +2929,20 @@ async function restoreSession(entry: PersistedSession): Promise { toast(tr('cockpit.restore_machine_offline', { name: entry.label || entry.name, machine: machineName(machineId) })); throw new Error('could not read the conversation list'); } + // Last look before anything starts: the awaits above are where an adoption lands. + const heldMeanwhile = liveTileHolding(entry); + if (heldMeanwhile) { merged(heldMeanwhile, 'while asking'); return; } + const liveIds = new Set(liveSessionsForPersist().filter(l => sameScope(l, entry)).map(l => l.sessionId).filter((x): x is string => !!x)); + // Conversations the other not-yet-restored entries are waiting for — an id-less entry must not + // take one of those out from under them. + const reserved = new Set(restorable.filter(r => sameScope(r, entry)).map((r) => r.sessionId).filter((x): x is string => !!x)); const target = resolveRestoreTarget(entry, ids, liveIds, reserved); const ok = await createSession({ path: entry.projectPath, name: entry.name, staleLevel: 'neutral', branch: null, dirty: 0, tileId: entry.tileId, sessionId: target.sessionId, mode: target.fresh ? 'new' : 'auto', label: entry.label ?? null, pinned: entry.pinned, agentId: owner, machineId }); // The decision, on the record. A restore that opens the wrong thing — or opens anything at all // when it should not have — is invisible from the outside, and reading it back off a machine // nobody is sitting at was impossible until this line. window.devdeck.logDiagnostic( - `restore ${entry.label || entry.name} on ${machineId === LOCAL_MACHINE_ID ? 'this pc' : machineName(machineId)}: ` + `restore ${entry.label || entry.name} on ${where}: ` + `saved=${entry.sessionId ?? '-'} onDisk=${ids.length} -> ${target.fresh ? 'fresh' : 'resume'} ${target.sessionId ?? '(new id)'} ok=${ok}`, 'info', 'restore', ); diff --git a/src/renderer/locales/en.json b/src/renderer/locales/en.json index 9978c6d..58bc0b8 100644 --- a/src/renderer/locales/en.json +++ b/src/renderer/locales/en.json @@ -464,7 +464,6 @@ "cockpit.restore_machine_offline": "Cannot restore “{name}” — {machine} is not reachable. It stays saved.", "cockpit.prev_machine_offline": "offline — restores when it connects", "cockpit.machine_offline_input": "{machine} is offline — nothing you type reaches it until it reconnects", - "cockpit.restore_machine_offline_n": "{n} sessions on {machine} will restore when it connects", "cockpit.restore_idle_left": "{n} sessions unused for over two days stay in previous sessions — click one to open it", "cockpit.stale_to_previous": "{n} sessions are no longer running on {machine} — moved to previous sessions", "cockpit.remote_file_unavailable": "That file is on {machine}, not on this computer.", diff --git a/src/renderer/locales/ja.json b/src/renderer/locales/ja.json index d98319a..b4f8d9f 100644 --- a/src/renderer/locales/ja.json +++ b/src/renderer/locales/ja.json @@ -464,7 +464,6 @@ "cockpit.restore_machine_offline": "「{name}」を復元できません — {machine} に接続できません。項目は保持されます。", "cockpit.prev_machine_offline": "オフライン — 接続時に復元", "cockpit.machine_offline_input": "{machine} はオフラインです — 再接続まで入力は届きません", - "cockpit.restore_machine_offline_n": "{machine} の {n} 件のセッションは接続時に復元されます", "cockpit.restore_idle_left": "2 日以上使われていないセッション {n} 件は以前のセッションに残しました — クリックで開きます", "cockpit.stale_to_previous": "{machine} で実行されていないセッション {n} 件を以前のセッションへ移動しました", "cockpit.remote_file_unavailable": "そのファイルはこの PC ではなく {machine} にあります。", diff --git a/src/renderer/locales/ko.json b/src/renderer/locales/ko.json index 8a903a0..d08c8c8 100644 --- a/src/renderer/locales/ko.json +++ b/src/renderer/locales/ko.json @@ -464,7 +464,6 @@ "cockpit.restore_machine_offline": "“{name}”을(를) 복원할 수 없습니다 — {machine}에 연결되지 않습니다. 항목은 그대로 남습니다.", "cockpit.prev_machine_offline": "오프라인 — 연결되면 복원", "cockpit.machine_offline_input": "{machine}이(가) 오프라인입니다 — 다시 연결될 때까지 입력이 전달되지 않습니다", - "cockpit.restore_machine_offline_n": "{machine}의 세션 {n}개는 연결되면 복원됩니다", "cockpit.restore_idle_left": "이틀 넘게 쓰지 않은 세션 {n}개는 이전 세션에 남겨 두었습니다 — 클릭하면 열립니다", "cockpit.stale_to_previous": "{machine}에서 더 이상 실행되지 않는 세션 {n}개를 이전 세션으로 옮겼습니다", "cockpit.remote_file_unavailable": "그 파일은 이 컴퓨터가 아니라 {machine}에 있습니다.", diff --git a/src/renderer/locales/zh.json b/src/renderer/locales/zh.json index 7a02377..9de9427 100644 --- a/src/renderer/locales/zh.json +++ b/src/renderer/locales/zh.json @@ -464,7 +464,6 @@ "cockpit.restore_machine_offline": "无法恢复“{name}” — 无法连接到 {machine}。该项目将保留。", "cockpit.prev_machine_offline": "离线 — 连接后恢复", "cockpit.machine_offline_input": "{machine} 已离线 — 重新连接前输入不会送达", - "cockpit.restore_machine_offline_n": "{machine} 上的 {n} 个会话将在连接后恢复", "cockpit.restore_idle_left": "超过两天未使用的 {n} 个会话保留在以前的会话中 — 点击即可打开", "cockpit.stale_to_previous": "{machine} 上已不再运行的 {n} 个会话已移至以前的会话", "cockpit.remote_file_unavailable": "该文件位于 {machine},而不是这台电脑上。", diff --git a/src/shared/cockpitPersist.test.ts b/src/shared/cockpitPersist.test.ts index 2584cb6..7dec4a5 100644 --- a/src/shared/cockpitPersist.test.ts +++ b/src/shared/cockpitPersist.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { cockpitNavigationId, cockpitNavigationIdForRuntime, persistedSessionKey, partitionLaunchRestore, takeLaunchRestoreCandidates, LAUNCH_RESTORE_MAX_IDLE_MS, removeAutoRestoreMatches, sanitizePersistedList, pickRestoreSessionId, resolveRestoreTarget, adoptRestorableMatch, pickDriftedSessionId, pickAdoptedSessionId, type PersistedSession, type SessionFileStat } from './cockpitPersist'; +import { cockpitNavigationId, cockpitNavigationIdForRuntime, persistedSessionKey, partitionLaunchRestore, takeLaunchRestoreCandidates, takeUpdateRestoreCandidates, LAUNCH_RESTORE_MAX_IDLE_MS, removeAutoRestoreMatches, sanitizePersistedList, pickRestoreSessionId, resolveRestoreTarget, adoptRestorableMatch, pickDriftedSessionId, pickAdoptedSessionId, type PersistedSession, type SessionFileStat } from './cockpitPersist'; describe('cockpit navigation identity', () => { it('keeps a live session context addressable after it becomes a persisted session', () => { @@ -135,6 +135,24 @@ describe('takeLaunchRestoreCandidates', () => { }); }); +describe('takeUpdateRestoreCandidates', () => { + const entry = (tileId: string, extra: Partial = {}): PersistedSession => + ({ tileId, projectPath: `C:/p/${tileId}`, name: tileId, sessionId: `s-${tileId}`, agentId: 'claude', ...extra }); + + it("restores only this machine's tiles itself; remote ones are left to adoption", () => { + const own = entry('own'); + const remote = entry('remote', { machineId: 'm-0123456789abcdef', label: '개선', pinned: true }); + const result = takeUpdateRestoreCandidates([remote, own]); + expect(result.own).toEqual([own]); + expect(result.remote).toEqual([remote]); + }); + + it('keeps the order of the entries it restores', () => { + const list = [entry('a'), entry('r1', { machineId: 'm-1' }), entry('b'), entry('c')]; + expect(takeUpdateRestoreCandidates(list).own.map((e) => e.tileId)).toEqual(['a', 'b', 'c']); + }); +}); + describe('sanitizePersistedList', () => { it('keeps one entry per conversation per machine, and lets a duplicate donate its label and pin', () => { // Two saved tiles for one conversation cannot both come back: the second finds the conversation diff --git a/src/shared/cockpitPersist.ts b/src/shared/cockpitPersist.ts index e894e7e..6669cf5 100644 --- a/src/shared/cockpitPersist.ts +++ b/src/shared/cockpitPersist.ts @@ -118,6 +118,25 @@ export function takeLaunchRestoreCandidates(list: readonly PersistedSession[]): return { saved, candidates }; } +/** + * The tiles an update relaunch restores ITSELF: this machine's. A remote tile's terminal is still + * running on its machine (the relaunch was ours), or that machine is relaunching too and restores it on + * its own launch — either way it comes back here by adoption when that machine announces it, and its + * saved entry supplies the tile id, label and pin. + * + * Restoring it from here as well raced that adoption. The announced terminal became a tile first; the + * restore for the same saved entry then found its conversation "already open in another tile" and + * started a NEW conversation under the same name on that machine — once per saved remote entry, per + * update. Measured on the reporter's pair: 7 → 14 → 29 sessions running on the other machine across two + * consecutive updates, every extra one an empty agent process nobody had asked for. + */ +export function takeUpdateRestoreCandidates(pending: readonly PersistedSession[]): { own: PersistedSession[]; remote: PersistedSession[] } { + const own: PersistedSession[] = []; + const remote: PersistedSession[] = []; + for (const entry of pending) (entry.machineId ? remote : own).push(entry); + return { own, remote }; +} + /** * Split a launch restore into what to reopen now and what to leave saved, by when each conversation * was last USED — `usedAtMs[i]` for `entries[i]`, normally its transcript's mtime.