diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0fdcdb2..bdc7ffa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -82,7 +82,7 @@ jobs: matrix: os: [ubuntu-latest, windows-latest] runs-on: ${{ matrix.os }} - timeout-minutes: 10 + timeout-minutes: 20 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 @@ -114,6 +114,22 @@ jobs: - name: Verify host and two viewers agree on committed session titles if: runner.os == 'Windows' run: node qa/session-sync.mjs + - name: Performance gate (fixture agent; animations, DOM churn, main-process stalls) + if: runner.os == 'Windows' + run: node qa/perf-gate.mjs + # One step per harness: a multi-line pwsh `run` fails only on its LAST command's exit code. + - name: Session images and attach + if: runner.os == 'Windows' + run: node qa/images.mjs + - name: Session images from a paired host + if: runner.os == 'Windows' + run: node qa/images-remote.mjs + - name: Update all Claude sessions + if: runner.os == 'Windows' + run: node qa/claude-update.mjs + - name: Restored agents start one at a time + if: runner.os == 'Windows' + run: node qa/restore-pacing.mjs - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 if: always() with: @@ -122,3 +138,7 @@ jobs: qa/shots/resilience/ qa/shots/maintenance/ qa/shots/session-sync/ + qa/shots/perf-gate/ + qa/shots/images/ + qa/shots/images-remote/ + qa/shots/claude-update/ diff --git a/README.md b/README.md index 4521a7a..f26ff78 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.38.0** makes closing a session reliable across paired PCs, fixes stale terminal drawing, adds **Apply update** for Claude Code on Windows, protects notes from being overwritten by another device, and cuts background CPU use ([release notes](docs/releases/1.38.0.md)). Use 1.38.0 on both machines for confirmed remote close and versioned notes; 1.37.9 added confirmed session renames and task conflict protection ([notes](docs/releases/1.37.9.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.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 @@ -32,6 +32,8 @@ DevDeck is a local-first desktop command center for **Claude Code, Codex, and An | --- | --- | | Find work that needs attention | Shared sidebar for working/waiting sessions; Quick Open with `Ctrl+Shift+P`; recent projects | | Resume without losing context | Provider-aware Open, existing terminal focus, previous-session restore, local Project Memory | +| See what the agent looked at | Image strip of a Claude session's screenshots, pasted images and written image files, with a full-size viewer — local or remote | +| Hand the agent a file | 📎 or drag-and-drop onto the terminal; images are sent to a remote PC and typed as its path | | Track a repository | Branch, dirty files, unpushed commits, activity, notes, project pin/hide and external actions | | Plan and inspect usage | Cross-project tasks and calendar; local token/API-equivalent cost estimates; supported live provider limits | | Use another PC | Opt-in DevDeck Link: the agent runs on the host; the viewer sends input and receives terminal output | diff --git a/docs/quality.md b/docs/quality.md index 2e63394..8c330c9 100644 --- a/docs/quality.md +++ b/docs/quality.md @@ -28,9 +28,11 @@ Results and screenshots are written to `qa/shots/maintenance/` and `qa/shots/res ## Manual harnesses -`qa:link` is a supervised two-app harness that may start installed provider CLIs. `qa:close` is a supervised two-app harness for the session close lifecycle (remote, offline, refused and bulk close, adoption focus, restart, Apply update); it starts the installed `claude` CLI in a throwaway repository and sends no prompts. `npm run qa:perf -- 4 --allow-live-agents` is a manual Windows benchmark and explicitly opts into real-agent startup. Performance QA refuses missing terminals, invisible workload, incomplete streaming/repaint activity and missing CPU samples instead of treating low CPU as success. It has not become a deterministic CI performance gate; that conversion remains a separate task. +`qa:link` is a supervised two-app harness that may start installed provider CLIs. `qa:close` is a supervised two-app harness for the session close lifecycle (remote, offline, refused and bulk close, adoption focus, restart, Apply update); it starts the installed `claude` CLI in a throwaway repository and sends no prompts. `npm run qa:perf -- 4 --allow-live-agents` is a manual Windows benchmark and explicitly opts into real-agent startup. Performance QA refuses missing terminals, invisible workload, incomplete streaming/repaint activity and missing CPU samples instead of treating low CPU as success. Its CPU percentages are for a person to read, not for CI. -`qa:images` (Windows) drives the cockpit image strip, full-size viewer and 📎 attach against an isolated home holding a transcript with a tool screenshot, a pasted image and a written image file; its fixture `claude` echoes what it is sent, so an attached path is read back off the terminal. `qa:update` (Windows) checks "Update all": a fixture `claude` prints Claude Code's failed-update footer, and the harness asserts that `claude update` ran with no session process alive and that every conversation was resumed by id. `qa:images-remote` (Windows) pairs a host and a viewer over real TLS and checks the same from the viewer: thumbnails and the full view cross the link, an attached JPEG lands on the host as PNG with the host path typed, and a non-image named `.png` or an unindexed id is refused over the link. None of these starts a real agent. +The CI performance gate is `qa:perf-gate` (Windows, every push): a fixture agent instead of a real one, and units that do not depend on the runner's speed — running CSS animations and DOM mutation records on an idle deck (both must be ~0), DOM mutations outside the terminals while two tiles repaint at 10fps (a per-chunk sidebar rebuild is ~8,000 against a limit of 400), and the main process's longest event-loop stall while working and while 20,000 lines stream. Each limit was checked by injecting the regression it guards against: an infinite CSS animation, a sidebar render per PTY chunk, and a 700ms synchronous block in the main process all fail it. Results go to `qa/shots/perf-gate/results.json`. + +`qa:images` (Windows) drives the cockpit image strip, full-size viewer and 📎 attach against an isolated home holding a transcript with a tool screenshot, a pasted image and a written image file; its fixture `claude` echoes what it is sent, so an attached path is read back off the terminal. `qa:update` (Windows) checks "Update all": a fixture `claude` prints Claude Code's failed-update footer, and the harness asserts that `claude update` ran with no session process alive and that every conversation was resumed by id. `qa:images-remote` (Windows) pairs a host and a viewer over real TLS and checks the same from the viewer: thumbnails and the full view cross the link, an attached JPEG lands on the host as PNG with the host path typed, and a non-image named `.png` or an unindexed id is refused over the link. `qa:restore-pacing` (Windows) checks that restored agents start one at a time: a fixture that draws for 2s while booting must not see the next one launched before it finished (measured before the fix: 26–49ms apart). None of these starts a real agent. Do not run these against a live development conversation. Do not equate a short loopback test with sleep/resume, VPN changes or multi-day field validation. diff --git a/docs/releases/1.39.0.md b/docs/releases/1.39.0.md new file mode 100644 index 0000000..1f39a5a --- /dev/null +++ b/docs/releases/1.39.0.md @@ -0,0 +1,38 @@ +# DevDeck 1.39.0 + +Includes 1.38.1, which was tagged but not published separately. + +## See what a session looked at + +- **Image strip.** The 🖼 button in a Claude session's header shows the screenshots its tools took (Playwright, Windows MCP, Blender and others), the images you pasted, and the image files it wrote — newest first, between the header and the terminal. Click one for a full-size view: ←/→ step through them, Esc closes, Save keeps a copy. Images that arrived while the strip was closed show as **+N** on the button. +- It reads the session's own transcript, where those screenshots exist and nowhere else, and only what was added since the last look (a 133 MB transcript: 100 ms the first time, about 1 ms after). +- Works for a session on another PC: thumbnails are made on that PC, so a 4K screenshot crosses the connection as a ~30 KB preview. +- Clicking an image path in a remote session's terminal now shows the image instead of "that file is on another PC". + +## Attach files + +- **📎** in the session header, or drag files onto the terminal. On this PC the file's own path is typed into the prompt. +- For a session on another PC, images (PNG, JPEG, GIF, WebP, BMP) are sent there as PNG — scaled down if needed — and the path on that PC is typed. Anything that could not be attached is reported. + +## Claude Code updates on Windows + +- On Windows, Claude Code cannot update itself while any session is running it, so with several sessions open its background update keeps failing. When a session shows "Auto-update failed", or "Update installed" while other sessions are open, the header offers **Update all (N)**: it waits for running turns (and permission prompts) to finish, stops every Claude session on this PC, runs `claude update`, and reopens each conversation. Sessions are saved first, so quitting DevDeck in the middle loses nothing. + +## Fewer freezes when sessions come back + +- Restored sessions now start one at a time, each after the previous one has finished starting. Before, a reboot, an update or **Restore all** started every agent at once. One `claude --resume` of a large conversation was measured at 1.6 GB and ~10 CPU-seconds while starting, four at once at 6.6 GB and 88 CPU-seconds within five seconds — enough to stall a PC for minutes. + +## Usage footer + +- No more daily "Login expired". Claude's access token lasts about eight hours and is renewed by Claude Code on its next reply; while the 30-day refresh token is still valid the footer now says **Awaiting refresh** and keeps the last numbers, with no sign-in prompt. Sign-in is offered only when the refresh token is gone or rejected. *(1.38.1)* + +## Security + +- A paired device with only "observe" permission can read just the images a session's own record lists. Image files are checked by their contents, must really be inside a project folder (symlinks and junctions are resolved), and full-size images are kept small enough that they can never drop the connection. + +## Under the hood + +- New automated checks run on every change: a performance gate (no animation or DOM churn on an idle deck, no main-process stalls while streaming — each limit verified by injecting the regression it guards against), the image strip locally and across two paired PCs, attach, Update all, and staggered restores. +- TypeScript 7, Vitest 5, Playwright 1.64; `@types/node` now matches Electron's Node 24. + +The image strip, attach and remote image viewing need 1.39.0 on the PC running the session; with an older host the strip stays empty and remote image paths keep the old message. diff --git a/package.json b/package.json index 280e720..a38251f 100644 --- a/package.json +++ b/package.json @@ -37,12 +37,14 @@ "qa:close": "npm run build && node qa/close.mjs", "qa:resilience": "npm run build && node qa/resilience.mjs", "qa:perf": "npm run build && node qa/perf.mjs", + "qa:perf-gate": "npm run build && node qa/perf-gate.mjs", "qa:maintenance": "npm run build && node qa/maintenance.mjs", "check:docs": "node scripts/check-docs.mjs", "qa:session-sync": "npm run build && node qa/session-sync.mjs", "qa:images": "npm run build && node qa/images.mjs", "qa:images-remote": "npm run build && node qa/images-remote.mjs", - "qa:update": "npm run build && node qa/claude-update.mjs" + "qa:update": "npm run build && node qa/claude-update.mjs", + "qa:restore-pacing": "npm run build && node qa/restore-pacing.mjs" }, "build": { "appId": "com.soursea.devdeck", diff --git a/qa/perf-gate.mjs b/qa/perf-gate.mjs new file mode 100644 index 0000000..086506d --- /dev/null +++ b/qa/perf-gate.mjs @@ -0,0 +1,196 @@ +// Deterministic performance gate (Windows CI): the regressions DevDeck has actually shipped, measured +// in units that do not depend on how fast the runner is. +// +// qa/perf.mjs is the manual benchmark — real agents, CPU percentages, a person reading the numbers. +// CPU percentages are meaningless across CI hardware, and real agents cost money, so this gate asks +// different questions of a fixture agent that prints a known workload: +// +// - IDLE: is anything animating or rewriting the DOM when nothing is happening? Every "the app got +// heavy" report so far was a screen that never stopped drawing (a CSS spinner composited at 60fps, +// a sidebar rebuilt on every PTY chunk). Counted, not timed. +// - WORKING: one tile repainting its whole screen at 10fps the way an agent's TUI does. Does the +// main process keep up (event-loop stall), and does the UI thread stay responsive (long tasks)? +// - STREAM: 20,000 lines into a visible terminal. The "other PC freezes for minutes" class is a main +// process that stops answering; its longest stall is the number that says so. +// +// Limits are set several times above what this machine measures, so a slow runner passes and a real +// regression (an order of magnitude) does not. Never starts a real agent. +import { _electron as electron } from 'playwright'; +import assert from 'node:assert/strict'; +import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs'; +import { execFileSync } from 'node:child_process'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { closeElectron } from './electron-lifecycle.mjs'; + +const root = dirname(dirname(fileURLToPath(import.meta.url))); +const out = resolve(process.env.DEVDECK_PERF_OUT || join(root, 'qa', 'shots', 'perf-gate')); +mkdirSync(out, { recursive: true }); +if (process.platform !== 'win32') { console.log('SKIP: Windows native PTY required'); process.exit(0); } + +const LIMITS = { + idleRunningAnimations: 0, // nothing may animate on a quiet deck + idleMutations: 40, // DOM mutation records in 6s of quiet (a rebuild-per-tick loop is thousands) + idleLongTasks: 0, + workingMainStallMs: 250, // longest main-process event-loop stall while a tile repaints at 10fps + workingLongestTaskMs: 250, + workingOutsideMutations: 400, // sidebar/header/footer while two tiles repaint at 10fps for 8s (a rebuild per chunk is ~10k) + streamMainStallMs: 600, // longest main-process stall while 20k lines stream + streamLongestTaskMs: 600, + streamSeconds: 45, // the stream must finish drawing at all +}; + +const sandbox = mkdtempSync(join(tmpdir(), 'devdeck-perf-gate-')); +const repo = join(sandbox, 'perf-workspace'); +const bin = join(sandbox, 'bin'); +const profile = join(sandbox, 'profile'); +const home = join(profile, 'home'); +for (const p of [repo, bin, home]) mkdirSync(p, { recursive: true }); +execFileSync('git', ['init', '-q', '-b', 'qa-perf'], { cwd: repo }); +// The fixture agent: a static screen at rest; "W" repaints the whole screen at 10fps for 8s with a +// Claude spinner in it (so the deck shows the session as working); "S" prints 20,000 lines. +writeFileSync(join(bin, 'agent.js'), ` +const out = (s) => process.stdout.write(s); +const E = '\\x1b'; +const screen = (title) => out(E + '[H' + E + '[2J' + title + '\\r\\n' + Array.from({ length: 30 }, (_, i) => 'line ' + i + ' ' + 'x'.repeat(80)).join('\\r\\n') + '\\r\\n> '); +screen('PERF_FIXTURE_READY'); +if (process.stdin.isTTY) process.stdin.setRawMode(true); +const glyphs = ['\\u00b7', '\\u2722', '\\u2733', '\\u2736', '\\u273b', '\\u273d']; +process.stdin.on('data', (d) => { + const k = d.toString(); + if (k === 'W') { + let i = 0; const end = Date.now() + 8000; + const t = setInterval(() => { + i++; + const rows = Array.from({ length: 34 }, (_, r) => 'row ' + r + ' frame ' + i + ' ' + 'x'.repeat(90)); + out(E + '[H' + rows.join('\\r\\n') + '\\r\\n' + glyphs[i % glyphs.length] + ' Thinking\\u2026 (' + i + 's \\u00b7 esc to interrupt)'); + if (Date.now() > end) { clearInterval(t); screen('WORK_DONE'); } + }, 100); + } else if (k === 'S') { + let n = 0; + const pump = () => { let chunk = ''; for (let j = 0; j < 500 && n < 20000; j++) chunk += 'perf line ' + (++n) + ' ' + 'y'.repeat(60) + '\\r\\n'; out(chunk); if (n < 20000) setImmediate(pump); else out('STREAM_DONE\\r\\n'); }; + pump(); + } +}); +setInterval(() => {}, 1 << 30); +`); +writeFileSync(join(bin, 'claude.cmd'), '@echo off\r\nnode "%~dp0agent.js" %*\r\n'); +const sids = ['8d4e5f6a-1111-4222-8333-944455556666', '9e5f6a7b-1111-4222-8333-944455556666']; +const tdir = join(home, '.claude', 'projects', repo.replace(/[^a-zA-Z0-9]/g, '-')); +mkdirSync(tdir, { recursive: true }); +for (const sid of sids) writeFileSync(join(tdir, sid + '.jsonl'), JSON.stringify({ type: 'user', timestamp: new Date().toISOString(), cwd: repo, sessionId: sid, message: { role: 'user', content: 'hi' } }) + '\n'); +writeFileSync(join(profile, 'state.json'), JSON.stringify({ projects: {}, settings: { + language: 'en', folders: [{ path: repo, kind: 'repo' }], viewMode: 'list', + cockpitSessions: sids.map((sid, i) => ({ tileId: 'tile-perf-' + i, projectPath: repo, name: 'perf-workspace', sessionId: sid, agentId: 'claude', label: 'Perf ' + i, lastActiveMs: Date.now() })), +} })); +const env = { ...process.env, HOME: home, USERPROFILE: home, CLAUDECODE: '', CLAUDE_CODE_SSE_PORT: '', CLAUDE_CODE_ENTRYPOINT: '' }; +const pathKey = Object.keys(env).find((k) => k.toLowerCase() === 'path') || 'PATH'; +env[pathKey] = bin + ';' + env[pathKey]; + +const app = await electron.launch({ args: ['.', '--user-data-dir=' + profile, '--no-sandbox'], cwd: root, env }); +const results = {}; +const failures = []; +const judge = (name, value, limit) => { + results[name] = { value, limit }; + const ok = value <= limit; + console.log(`${ok ? 'PASS' : 'FAIL'} ${name}: ${value} (limit ${limit})`); + if (!ok) failures.push(name); +}; +try { + const win = await app.firstWindow(); + win.setDefaultTimeout(30000); + await win.setViewportSize?.({ width: 1500, height: 950 }).catch(() => {}); + await win.waitForSelector('#shell-session-groups .shell-session', { timeout: 30000 }); + // Both tiles running, one on screen. + const restoreAll = win.getByRole('button', { name: /Restore all/i }); + if (await restoreAll.count()) await restoreAll.first().click(); + await win.waitForFunction(() => document.querySelectorAll('.ck-term').length >= 2, null, { timeout: 30000 }); + await win.locator('#shell-session-groups .shell-session').first().click(); + const ids = await win.evaluate(() => window.devdeck.cockpit.liveSessions().then((list) => list.map((s) => s.id))); + assert.ok(ids.length >= 2, 'two sessions running: ' + JSON.stringify(ids)); + // Read off each session's output buffer, not the visible rows: a screen printed before the terminal + // reached its final size can scroll the marker out of view, which says nothing about performance. + const allPrinted = (marker, ms) => win.waitForFunction(async ([list, m]) => { + const bufs = await Promise.all(list.map((i) => window.devdeck.cockpit.sessionBuffer(i))); + return bufs.every((b) => b.includes(m)); + }, [ids, marker], { timeout: ms, polling: 250 }); + await allPrinted('PERF_FIXTURE_READY', 30000); + // The workload goes to every running session — one is on screen, the other works in the background, + // which is how a deck is actually used. + const sendAll = (key) => win.evaluate(([list, k]) => { for (const i of list) window.devdeck.cockpit.input(i, k); }, [ids, key]); + await win.waitForTimeout(3000); // let restore, meta and git refreshes settle + + // ---- instruments --------------------------------------------------------------------------- + await win.evaluate(() => { + window.__gate = { long: [], mutations: 0, outside: 0 }; + new PerformanceObserver((l) => { for (const e of l.getEntries()) window.__gate.long.push(Math.round(e.duration)); }).observe({ entryTypes: ['longtask'] }); + new MutationObserver((records) => { + window.__gate.mutations += records.length; + // Outside the terminals: the sidebar, header and footer. A terminal redraws by design while its + // agent works; the chrome around it must not rebuild along with every chunk. + for (const r of records) { const t = r.target.nodeType === 1 ? r.target : r.target.parentElement; if (!t?.closest?.('.ck-terms')) window.__gate.outside++; } + }).observe(document.body, { subtree: true, childList: true, attributes: true, characterData: true }); + }); + // Main-process event-loop stall: a 20ms timer's lateness, the longest of the window. + await app.evaluate(() => { + globalThis.__lag = { max: 0, last: Date.now() }; + globalThis.__lagTimer = setInterval(() => { const now = Date.now(); globalThis.__lag.max = Math.max(globalThis.__lag.max, now - globalThis.__lag.last - 20); globalThis.__lag.last = now; }, 20); + }); + const reset = async () => { + await win.evaluate(() => { window.__gate.long = []; window.__gate.mutations = 0; window.__gate.outside = 0; }); + await app.evaluate(() => { globalThis.__lag.max = 0; globalThis.__lag.last = Date.now(); }); + }; + const read = async () => ({ + ...(await win.evaluate(() => ({ long: [...window.__gate.long], mutations: window.__gate.mutations, outside: window.__gate.outside }))), + stall: await app.evaluate(() => globalThis.__lag.max), + }); + + // ---- IDLE ---------------------------------------------------------------------------------- + await reset(); + await win.waitForTimeout(6000); + const idle = await read(); + const running = await win.evaluate(() => document.getAnimations().filter((a) => a.playState === 'running').map((a) => { + const el = a.effect?.target; + return `${a.animationName ?? a.constructor.name} on ${el?.className ?? el?.tagName ?? '?'}`; + })); + if (running.length) console.log(' running animations: ' + running.join(' | ')); + judge('idle running animations', running.length, LIMITS.idleRunningAnimations); + judge('idle DOM mutation records (6s)', idle.mutations, LIMITS.idleMutations); + judge('idle long tasks', idle.long.length, LIMITS.idleLongTasks); + await win.screenshot({ path: join(out, 'idle.png') }); + + // ---- WORKING ------------------------------------------------------------------------------- + await reset(); + await sendAll('W'); + await allPrinted('WORK_DONE', 30000); + const working = await read(); + judge('working: main-process stall ms', working.stall, LIMITS.workingMainStallMs); + judge('working: longest UI task ms', Math.max(0, ...working.long), LIMITS.workingLongestTaskMs); + judge('working: DOM mutation records outside the terminals (8s)', working.outside, LIMITS.workingOutsideMutations); + + // ---- STREAM -------------------------------------------------------------------------------- + await win.waitForTimeout(1500); + await reset(); + const t0 = Date.now(); + await sendAll('S'); + await allPrinted('STREAM_DONE', LIMITS.streamSeconds * 1000); + // ...and drawn: the visible terminal has caught up with it. + await win.waitForFunction(() => [...document.querySelectorAll('.ck-term.show .xterm-rows')].some((r) => r.textContent.includes('STREAM_DONE')), null, { timeout: LIMITS.streamSeconds * 1000 }); + const streamSeconds = Math.round((Date.now() - t0) / 100) / 10; + // The window includes what follows a stream — scrollback trimming, reflow, the deck catching up — + // so a stall that lands just after the last line is still inside it. + await win.waitForTimeout(3000); + const stream = await read(); + judge('stream: seconds to draw 20k lines', streamSeconds, LIMITS.streamSeconds); + judge('stream: main-process stall ms', stream.stall, LIMITS.streamMainStallMs); + judge('stream: longest UI task ms', Math.max(0, ...stream.long), LIMITS.streamLongestTaskMs); + await win.screenshot({ path: join(out, 'stream.png') }); +} catch (e) { + console.log('FAIL harness: ' + (e?.message ?? e)); + failures.push('harness'); +} finally { + writeFileSync(join(out, 'results.json'), JSON.stringify({ results, failures }, null, 2)); + await closeElectron(app).catch(() => {}); +} +if (failures.length) { console.log(`\nFAIL: ${failures.join(', ')}`); process.exitCode = 1; } else console.log('\nPASS: performance gate'); diff --git a/qa/restore-pacing.mjs b/qa/restore-pacing.mjs new file mode 100644 index 0000000..eae3ae3 --- /dev/null +++ b/qa/restore-pacing.mjs @@ -0,0 +1,78 @@ +// Windows harness: restored agents start ONE AT A TIME — each only after the one before it finished +// starting. A fixture agent takes 2s to boot (the way `claude --resume` of a large transcript does) +// and records when it was launched; the gaps between launches must cover that boot, both for the +// sessions put back at launch and for "Restore all". Never starts a real agent. +// Run with npm run qa:restore-pacing. +import { _electron as electron } from 'playwright'; +import assert from 'node:assert/strict'; +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync } from 'node:fs'; +import { execFileSync } from 'node:child_process'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { closeElectron } from './electron-lifecycle.mjs'; + +const root = dirname(dirname(fileURLToPath(import.meta.url))); +if (process.platform !== 'win32') { console.log('SKIP: Windows native PTY required'); process.exit(0); } +const sandbox = mkdtempSync(join(tmpdir(), 'devdeck-restore-pacing-')); +const repo = join(sandbox, 'pacing-workspace'); +const bin = join(sandbox, 'bin'); +const profile = join(sandbox, 'profile'); +const home = join(profile, 'home'); +const log = join(sandbox, 'starts.log'); +for (const p of [repo, bin, home]) mkdirSync(p, { recursive: true }); +execFileSync('git', ['init', '-q', '-b', 'qa-pacing'], { cwd: repo }); +const BOOT_MS = 2000; +writeFileSync(join(bin, 'agent.js'), ` +require('fs').appendFileSync(${JSON.stringify(log)}, Date.now() + ' ' + process.argv.slice(2).join(' ') + '\\n'); +const t = setInterval(() => process.stdout.write('.'), 100); // busy drawing while it boots +setTimeout(() => { clearInterval(t); process.stdout.write('\\r\\nPACING_READY\\r\\n> '); }, ${BOOT_MS}); +if (process.stdin.isTTY) process.stdin.setRawMode(true); +process.stdin.on('data', () => {}); +setInterval(() => {}, 1 << 30); +`); +writeFileSync(join(bin, 'claude.cmd'), '@echo off\r\nnode "%~dp0agent.js" %*\r\n'); + +const N = 4; +const sids = Array.from({ length: N }, (_, i) => `${i + 1}a2b3c4d-1111-4222-8333-944455556666`); +const tdir = join(home, '.claude', 'projects', repo.replace(/[^a-zA-Z0-9]/g, '-')); +mkdirSync(tdir, { recursive: true }); +for (const sid of sids) writeFileSync(join(tdir, sid + '.jsonl'), JSON.stringify({ type: 'user', timestamp: new Date().toISOString(), cwd: repo, sessionId: sid, message: { role: 'user', content: 'hi' } }) + '\n'); +writeFileSync(join(profile, 'state.json'), JSON.stringify({ projects: {}, settings: { + language: 'en', folders: [{ path: repo, kind: 'repo' }], viewMode: 'list', + cockpitSessions: sids.map((sid, i) => ({ tileId: 'tile-pace-' + i, projectPath: repo, name: 'pacing-workspace', sessionId: sid, agentId: 'claude', label: 'Pace ' + i, lastActiveMs: Date.now() })), +} })); +const env = { ...process.env, HOME: home, USERPROFILE: home, CLAUDECODE: '', CLAUDE_CODE_SSE_PORT: '', CLAUDE_CODE_ENTRYPOINT: '' }; +const pathKey = Object.keys(env).find((k) => k.toLowerCase() === 'path') || 'PATH'; +env[pathKey] = bin + ';' + env[pathKey]; + +const starts = () => (existsSync(log) ? readFileSync(log, 'utf8').trim().split(/\r?\n/).filter(Boolean).map((l) => Number(l.split(' ')[0])) : []); +const gaps = (ts) => ts.slice(1).map((t, i) => t - ts[i]); +const app = await electron.launch({ args: ['.', '--user-data-dir=' + profile, '--no-sandbox', '--disable-gpu'], cwd: root, env }); +let failed = false; +try { + const win = await app.firstWindow(); + win.setDefaultTimeout(60000); + await win.waitForSelector('#shell-session-groups .shell-session', { timeout: 30000 }); + // Whatever launch put back, then the rest by "Restore all". + const restoreAll = win.getByRole('button', { name: /Restore all/i }); + const until = Date.now() + 60000; + while (Date.now() < until) { + if (await restoreAll.count() && await restoreAll.first().isVisible() && await restoreAll.first().isEnabled()) { await restoreAll.first().click(); break; } + if (starts().length >= N) break; + await win.waitForTimeout(500); + } + while (Date.now() < until && starts().length < N) await win.waitForTimeout(500); + const ts = starts(); + console.log(`starts: ${ts.length} gaps(ms): ${gaps(ts).join(', ')}`); + assert.equal(ts.length, N, 'every saved session started'); + // Each agent draws for BOOT_MS and then needs a quiet moment before the next may start. + for (const g of gaps(ts)) assert.ok(g >= BOOT_MS, `next agent started ${g}ms after the previous one, before it finished starting`); + console.log('PASS restored agents start one at a time, each after the previous finished starting'); +} catch (e) { + console.log('FAIL', e?.message ?? e); + failed = true; +} finally { + await closeElectron(app).catch(() => {}); +} +if (failed) process.exitCode = 1; diff --git a/src/renderer/cockpitView.ts b/src/renderer/cockpitView.ts index 3cbaddc..00d2036 100644 --- a/src/renderer/cockpitView.ts +++ b/src/renderer/cockpitView.ts @@ -318,7 +318,47 @@ async function autoRestoreAfterUpdate(pending: PersistedSession[]): Promise { + if (!tileId) return; + const started = Date.now(); + while (Date.now() - started < START_SETTLE_MAX_MS) { + const l = [...live.values()].find((x) => x.tileId === tileId); + if (!l || l.session.status === 'exited') return; + const now = Date.now(); + const hasOutput = l.lastDataAt > l.openedAt; + if (hasOutput && now - l.lastDataAt >= START_QUIET_MS) return; + if (!hasOutput && now - l.openedAt >= START_SILENT_MS) return; + await new Promise((r) => setTimeout(r, 250)); + } +} + +/** Restore saved entries one at a time, each starting only once the one before has finished starting. */ +async function restoreInTurn(entries: PersistedSession[]): Promise { + for (const entry of entries) { + await restoreSession(entry); + await settleAfterStart(entry.tileId); + } } /** @@ -348,6 +388,7 @@ async function autoRestoreAtLaunch(candidates: PersistedSession[]): Promise l.openedSessionId === entry.sessionId)) continue; await restoreSession(entry); + await settleAfterStart(entry.tileId); } } @@ -2271,6 +2312,7 @@ async function updateAllClaude(): Promise { // Every conversation comes back whether or not the update did: the sessions were stopped for it. for (const plan of plans) { if (await reopen(plan)) { restorable = restorable.filter((r) => r.tileId !== plan.entry.tileId); persist(); renderList(); } + await settleAfterStart(plan.tileId); } } finally { claudeUpdateRunning = false; renderHeader(); @@ -2602,7 +2644,7 @@ function renameInput(id: string, current: string): HTMLElement { } async function restoreAll(): Promise { - for (const entry of [...restorable]) await restoreSession(entry); + await restoreInTurn([...restorable]); } function forgetSession(entry: PersistedSession): void {