fix(sessions): reprendre/forker une session managée morte par UUID (résout le 404)
Le bouton « Reprendre » envoie l'UUID Arboretum, mais resume/fork ne résolvaient l'id que via la découverte (indexée par claudeSessionId, qui exclut justement les sessions managées) → 404 systématique après fermeture. Ajoute PtyManager.resumeTargetById (cwd d'origine + claudeSessionId + groupe lus en DB par UUID) et un résolveur unifié dans resume ET fork : session managée morte par UUID, sinon session claude externe découverte par claudeSessionId. Garde-fous anti-resume d'une session vivante conservés.
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -201,6 +201,24 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
||||
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()) {
|
||||
|
||||
@@ -26,24 +26,29 @@ export function registerSessionRoutes(app: FastifyInstance, manager: PtyManager,
|
||||
}
|
||||
});
|
||||
|
||||
// Reprise d'une session morte : nouveau PTY managé `--resume <id>` 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 <claudeSessionId>` 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 <id> --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) {
|
||||
|
||||
@@ -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-'));
|
||||
|
||||
Reference in New Issue
Block a user