fix(server, desktop): plus de transcript perdu, et la mise à jour s'applique toute seule

Deux défauts vécus sur le poste, tous deux « invisibles » jusqu'à ce qu'on regarde.

1. Une console Claude ouverte depuis l'app affichait « Transcript saving is off,
   inherited CLAUDE_CODE_CHILD_SESSION marker ». Le daemon avait été lancé depuis
   une session Claude Code, il héritait donc de ses marqueurs d'exécution et les
   repassait à CHAQUE session qu'il lance. Le CLI se croyait sous-session et
   coupait la sauvegarde de son transcript : plus d'historique, plus de --resume,
   claudeSessionId restant null (et avec lui l'état fin busy/waiting/idle, ce qui
   explique les sessions sans activité détectée). L'environnement des PTY est
   desormais assaini de ces marqueurs, pour `claude` comme pour les shells (un
   `claude` tapé à la main en héritait aussi). La configuration légitime de
   l'utilisateur (CLAUDE_CONFIG_DIR, ANTHROPIC_*, proxies) passe intacte.
   Vérifié par acceptance-p17 : le daemon de test est lancé avec un environnement
   volontairement pollué, et le PTY n'en voit plus rien.

2. Une mise à jour installée à chaud demandait encore une manipulation. La 0.2.4
   détectait le remplacement du binaire et proposait un dialogue « Restart now » :
   le travail restait à la charge de l'utilisateur. L'app redémarre maintenant
   d'elle-même quand cela ne coûte rien, c'est-à-dire le cas courant, et ne
   demande que s'il y a quelque chose à perdre : des sessions vivantes à
   interrompre (le dialogue dit combien) ou un daemon injoignable. Un « Later »
   reste définitif pour cette version : rien ne redémarre dans le dos de
   personne. La détection ne dépend plus d'un retour par le tray ou le Dock : un
   `stat` toutes les 30 s la couvre même fenêtre ouverte, par poll et non par
   `fs.watch`, qui ne voit souvent rien quand un paquet remplace un binaire ou
   tout un répertoire.
This commit is contained in:
2026-08-05 11:35:51 +02:00
parent dc8c7c9534
commit 17e95754b1
6 changed files with 289 additions and 14 deletions
+69 -2
View File
@@ -1,8 +1,8 @@
import { describe, expect, it } from 'vitest';
import { mkdtempSync, utimesSync, writeFileSync } from 'node:fs';
import { mkdtempSync, rmSync, utimesSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { installChanged, readInstallStamp } from '../src/main/upgrade-watch';
import { decideUpgradeAction, installChanged, pollInstall, readInstallStamp } from '../src/main/upgrade-watch';
describe('upgrade-watch', () => {
it('lit une empreinte de fichier, et rien pour un chemin absent', () => {
@@ -31,3 +31,70 @@ describe('upgrade-watch', () => {
expect(installChanged(null, null)).toBe(false);
});
});
// Objectif produit : ZÉRO manipulation quand c'est sans risque. Un redémarrage ne détruit qu'une
// chose, les sessions vivantes hébergées par le daemon : elles seules justifient de demander.
describe('decideUpgradeAction', () => {
it('rien à faire si l’installation n’a pas changé', () => {
expect(decideUpgradeAction({ changed: false, liveSessions: 0, dismissed: false })).toBe('none');
expect(decideUpgradeAction({ changed: false, liveSessions: 3, dismissed: false })).toBe('none');
});
it('aucune session vivante : redémarrage automatique, sans dialogue', () => {
expect(decideUpgradeAction({ changed: true, liveSessions: 0, dismissed: false })).toBe('restart');
});
it('des sessions tournent : on demande avant de les interrompre', () => {
expect(decideUpgradeAction({ changed: true, liveSessions: 1, dismissed: false })).toBe('ask');
expect(decideUpgradeAction({ changed: true, liveSessions: 9, dismissed: false })).toBe('ask');
});
it('état inconnu (daemon injoignable) : on demande, par prudence', () => {
expect(decideUpgradeAction({ changed: true, liveSessions: null, dismissed: false })).toBe('ask');
});
it('un « Later » est définitif : jamais de redémarrage dans le dos de l’utilisateur', () => {
expect(decideUpgradeAction({ changed: true, liveSessions: 0, dismissed: true })).toBe('none');
expect(decideUpgradeAction({ changed: true, liveSessions: null, dismissed: true })).toBe('none');
});
});
describe('pollInstall', () => {
it('signale le premier changement, puis s’arrête de lui-même', async () => {
const dir = mkdtempSync(join(tmpdir(), 'arb-poll-'));
const file = join(dir, 'app');
try {
writeFileSync(file, 'v1');
const boot = readInstallStamp(file);
const seen: number[] = [];
const handle = pollInstall({ path: file, intervalMs: 10, boot, onChanged: (c) => seen.push(c.size) });
await new Promise((r) => setTimeout(r, 40));
expect(seen).toHaveLength(0); // rien n'a bougé
writeFileSync(file, 'v2-plus-long');
await new Promise((r) => setTimeout(r, 80));
expect(seen).toHaveLength(1);
// un second changement ne doit PAS rappeler : le poll s'arrête au premier
writeFileSync(file, 'v3-encore-plus-long');
await new Promise((r) => setTimeout(r, 60));
expect(seen).toHaveLength(1);
handle.stop();
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it('un chemin illisible ne déclenche rien', async () => {
const seen: string[] = [];
const handle = pollInstall({
path: '/definitely/not/here',
intervalMs: 10,
boot: { ino: 1, mtimeMs: 1, size: 1 },
onChanged: () => seen.push('x'),
});
await new Promise((r) => setTimeout(r, 50));
expect(seen).toHaveLength(0);
handle.stop();
});
});