diff --git a/packages/server/scripts/acceptance-p2.mjs b/packages/server/scripts/acceptance-p2.mjs index a1762ed..3bb8e97 100644 --- a/packages/server/scripts/acceptance-p2.mjs +++ b/packages/server/scripts/acceptance-p2.mjs @@ -144,6 +144,37 @@ try { const out = c.state.outputs.get(att?.channel) ?? ''; check('resume lance `--resume sid-dead` dans le bon cwd', out.includes('args=[--resume sid-dead]') && out.includes(`cwd=${workDir}`), out.replace(/\s+/g, ' ').slice(0, 120)); + // --- Régression « Reprendre » : reprise d'une session MANAGÉE morte par son UUID Arboretum --- + // (le bouton web envoie l'UUID managé, pas le claudeSessionId ; le serveur doit le résoudre en DB). + const createManaged = await fetch(`${ORIGIN}/api/v1/sessions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Origin: ORIGIN, Cookie: cookie }, + body: JSON.stringify({ cwd: workDir, command: 'claude' }), + }); + const managed = (await createManaged.json()).session; + check('création session managée claude → 201 (pid)', createManaged.status === 201 && managed?.pid > 0); + + // Simule la capture du claudeSessionId : entrée registre pour le pid du PTY managé (pollée toutes les 400ms). + writeFileSync( + join(sessions, `${managed.pid}.json`), + JSON.stringify({ pid: managed.pid, sessionId: 'sid-managed', cwd: workDir, status: 'idle' }), + ); + const captured = await c.waitMsg( + (m) => m.type === 'session_update' && m.session?.id === managed.id && m.session?.claudeSessionId === 'sid-managed', + ); + check('claudeSessionId capturé depuis le registre (session managée)', !!captured); + + // Fermeture (kill) puis reprise par UUID managé → 201 dans le cwd d'origine (avant le fix : 404). + const killManaged = await fetch(`${ORIGIN}/api/v1/sessions/${managed.id}`, { method: 'DELETE', headers: { Origin: ORIGIN, Cookie: cookie } }); + check('kill session managée → 200', killManaged.status === 200); + await sleep(400); // laisse handleExit persister ended_at + const resumeManaged = await postJson(`/api/v1/sessions/${managed.id}/resume`, cookie); + const resumedManaged = (await resumeManaged.json()).session; + check( + 'resume d’une session MANAGÉE morte par UUID → 201', + resumeManaged.status === 201 && resumedManaged?.command === 'claude' && resumedManaged?.source === 'managed' && resumedManaged?.cwd === workDir, + ); + // Broadcast : une nouvelle session découverte est poussée via session_update au rafraîchissement périodique. writeJsonl(workDir, 'sid-new'); const pushed = await c.waitMsg((m) => m.type === 'session_update' && m.session?.id === 'sid-new', 13000); diff --git a/packages/server/src/core/pty-manager.ts b/packages/server/src/core/pty-manager.ts index b4daf80..c9f88db 100644 --- a/packages/server/src/core/pty-manager.ts +++ b/packages/server/src/core/pty-manager.ts @@ -201,6 +201,24 @@ export class PtyManager extends EventEmitter { return { addedDirs: parseAddedDirs(row.added_dirs), groupId: row.group_id }; } + /** + * Cible de reprise d'une session managée MORTE (P2/P6), résolue par UUID Arboretum : son cwd + * d'origine, son claudeSessionId et son contexte de groupe, lus en DB. null si l'id ne correspond + * pas à une session managée morte, reprenable (claude + claudeSessionId connu). + * Complète `DiscoveryService.getDiscovered`, qui ne couvre QUE les sessions claude EXTERNES + * (une managée connue est justement exclue de la découverte). + */ + resumeTargetById(id: string): { cwd: string; claudeSessionId: string; addedDirs: string[]; groupId: string | null } | null { + if (this.live.has(id)) return null; // vivante : pas de resume direct (fork via le même chemin) + const row = this.db + .prepare( + "SELECT cwd, claude_session_id, added_dirs, group_id FROM sessions WHERE id = ? AND ended_at IS NOT NULL AND claude_session_id IS NOT NULL AND command = 'claude'", + ) + .get(id) as { cwd: string; claude_session_id: string; added_dirs: string | null; group_id: string | null } | undefined; + if (!row) return null; + return { cwd: row.cwd, claudeSessionId: row.claude_session_id, addedDirs: parseAddedDirs(row.added_dirs), groupId: row.group_id }; + } + /** Session managée VIVANTE portant ce claudeSessionId (garde-fou anti-resume d'une session vivante). */ findLiveByClaudeSessionId(claudeSessionId: string): SessionSummary | null { for (const s of this.live.values()) { diff --git a/packages/server/src/routes/sessions.ts b/packages/server/src/routes/sessions.ts index 0d440ae..7615fd7 100644 --- a/packages/server/src/routes/sessions.ts +++ b/packages/server/src/routes/sessions.ts @@ -26,24 +26,29 @@ export function registerSessionRoutes(app: FastifyInstance, manager: PtyManager, } }); - // Reprise d'une session morte : nouveau PTY managé `--resume ` DANS SON CWD D'ORIGINE (spike S1). - // Le cwd n'est jamais fourni par le client : il est lu sur disque via la découverte. + // Reprise d'une session morte : nouveau PTY managé `--resume ` DANS SON CWD D'ORIGINE + // (spike S1). Le cwd n'est JAMAIS fourni par le client. Deux origines d'id possibles : + // - session managée morte (id = UUID Arboretum) → cwd + claudeSessionId + groupe lus en DB ; + // - session claude EXTERNE découverte (id = claudeSessionId) → cwd lu sur disque. app.post('/api/v1/sessions/:id/resume', async (req, reply) => { const { id } = req.params as { id: string }; - const discovered = discovery.getDiscovered(id); - if (!discovered) { + const managed = manager.resumeTargetById(id); + const discovered = managed ? null : discovery.getDiscovered(id); + if (!managed && !discovered) { return reply.status(404).send({ error: { code: 'NOT_FOUND', message: 'No resumable session with this id' } }); } + const cwd = managed ? managed.cwd : discovered!.cwd; + const claudeSessionId = managed ? managed.claudeSessionId : id; // Garde-fou anti-corruption : jamais de resume direct d'une session vivante (vérif FRAÎCHE). - if (discovery.isClaudeSessionLive(id) || manager.findLiveByClaudeSessionId(id)) { + if (discovery.isClaudeSessionLive(claudeSessionId) || manager.findLiveByClaudeSessionId(claudeSessionId)) { return reply.status(409).send({ error: { code: 'SESSION_LIVE', message: 'Session is live — fork it instead' } }); } try { // Session de groupe (P6) : re-relie les mêmes répertoires (--add-dir) et son groupe au resume. - const ctx = manager.groupSessionContext(id); + const ctx = managed ? { addedDirs: managed.addedDirs, groupId: managed.groupId } : manager.groupSessionContext(claudeSessionId); const session = manager.spawn({ - cwd: discovered.cwd, - resume: { claudeSessionId: id }, + cwd, + resume: { claudeSessionId }, ...(ctx?.addedDirs.length ? { addDirs: ctx.addedDirs } : {}), ...(ctx?.groupId ? { groupId: ctx.groupId } : {}), }); @@ -56,14 +61,18 @@ export function registerSessionRoutes(app: FastifyInstance, manager: PtyManager, }); // Fork : duplique une session (vivante ou morte) sans la corrompre (`--resume --fork-session`). + // Même résolution d'id que /resume (managée morte par UUID, sinon externe découverte par claudeSessionId). app.post('/api/v1/sessions/:id/fork', async (req, reply) => { const { id } = req.params as { id: string }; - const discovered = discovery.getDiscovered(id); - if (!discovered) { + const managed = manager.resumeTargetById(id); + const discovered = managed ? null : discovery.getDiscovered(id); + if (!managed && !discovered) { return reply.status(404).send({ error: { code: 'NOT_FOUND', message: 'No session with this id to fork' } }); } + const cwd = managed ? managed.cwd : discovered!.cwd; + const claudeSessionId = managed ? managed.claudeSessionId : id; try { - const session = manager.spawn({ cwd: discovered.cwd, resume: { claudeSessionId: id, fork: true } }); + const session = manager.spawn({ cwd, resume: { claudeSessionId, fork: true } }); const res: SessionResponse = { session }; return reply.status(201).send(res); } catch (err) { diff --git a/packages/server/test/pty-manager.test.ts b/packages/server/test/pty-manager.test.ts index d1e1211..ad5a3da 100644 --- a/packages/server/test/pty-manager.test.ts +++ b/packages/server/test/pty-manager.test.ts @@ -187,6 +187,34 @@ describe('PtyManager (pty mocké)', () => { expect(listed).toMatchObject({ live: false, resumable: true, claudeSessionId: 'sid-known' }); }); + it('resumeTargetById : session claude morte → cwd + claudeSessionId + groupe ; null sinon', () => { + const d1 = mkdtempSync(join(tmpdir(), 'arb-rt-')); + try { + const summary = manager.spawn({ cwd, command: 'claude', addDirs: [d1], groupId: 'grpR' }); + db.prepare('UPDATE sessions SET claude_session_id = ? WHERE id = ?').run('cs-resume', summary.id); + // vivante → null (resume direct interdit ; fork passe par le même chemin) + expect(manager.resumeTargetById(summary.id)).toBeNull(); + lastPty().emitExit(0); + // morte avec claudeSessionId connu → cible complète + expect(manager.resumeTargetById(summary.id)).toEqual({ + cwd, + claudeSessionId: 'cs-resume', + addedDirs: [d1], + groupId: 'grpR', + }); + // id inconnu → null + expect(manager.resumeTargetById('nope')).toBeNull(); + } finally { + rmSync(d1, { recursive: true, force: true }); + } + }); + + it('resumeTargetById : session bash morte (sans claudeSessionId) → null', () => { + const { summary, pty } = spawnBash(); + pty.emitExit(0); + expect(manager.resumeTargetById(summary.id)).toBeNull(); + }); + it('capture le claudeSessionId via le registre (poll par pid) → findLiveByClaudeSessionId', () => { vi.useFakeTimers(); const sessDir = mkdtempSync(join(tmpdir(), 'arb-sess-'));