严重: qodercli ACP session/new 创建的 Session 无法跨进程恢复

Bug Report: qodercli ACP session/new 创建的 Session 无法跨进程恢复

Issue Description

通过 ACP 协议 session/new 创建的 session,进程被杀后重新启动,session/load 无法恢复该 session。原因是 ACP session/new 只写入了 state.json,没有生成 {sessionId}-session.json 元数据文件,导致 session/loadsession/list 无法发现和加载该 session。

Steps to Reproduce

  1. 启动 qodercli ACP 模式:qodercli --acp
  2. 发送 initialize 请求,确认 agentCapabilities.loadSession: true
  3. 发送 session/new 创建新 session,记录返回的 sessionId
  4. 发送 session/prompt 发送一条消息,确认正常回复
  5. 尝试发送 session/close返回 -32601 Method not found
  6. 杀掉进程
  7. 重新启动 qodercli --acp
  8. 发送 initialize
  9. 发送 session/list列表中不包含步骤 3 创建的 session
  10. 发送 session/load 使用步骤 3 的 sessionId → 返回 -32603 Internal error: Invalid session identifier

Expected Behavior

  1. session/close 应被实现,用于通知 agent 持久化 session 状态
  2. session/new 创建的 session 应在进程重启后仍然可通过 session/list 发现、通过 session/load 恢复
  3. 或在 ACP 实现完整之前,agentCapabilities.loadSession 不应宣告为 true

Actual Behavior

  • session/close 返回 -32601 Method not found(未实现)
  • session/new 仅在 ~/.qoder/projects/{project}/ 下创建 {sessionId}/state.json,不生成 {sessionId}-session.json 元数据文件
  • 非 ACP 模式(CLI 直接使用)的 session 有 {id}-session.json + {id}.jsonl + {id}/state.json,可正常恢复
  • 进程重启后 session/list 只列出非 ACP 模式创建的旧 session,ACP 创建的不可见
  • session/load 始终失败,提示 “Searched for sessions in ~/.qoder/projects/{project}” 但找不到

磁盘对比:

创建方式 磁盘文件
CLI 直接使用 {id}-session.json + {id}.jsonl + {id}/state.json
ACP session/new {id}/state.json only

Operating System

macOS 15.x (Darwin 25.2.0, arm64)

Current Qoder Version

0.2.50.2.8 均存在此问题。0.2.8 未修复。


附:独立 ACP 协议测试脚本,可直接运行验证:

node /tmp/test-qodercli-resume.mjs

// Standalone ACP protocol test for qodercli session resume.
// Tests whether qodercli supports session persistence across process restarts.
//
// Usage: node /tmp/test-qodercli-resume.mjs [path-to-qodercli] [working-dir]

import { spawn } from ‘node:child_process’;
import { createInterface } from ‘node:readline’;
import * as path from ‘node:path’;
import * as os from ‘node:os’;

const QODERCLI = process.argv[2] || ‘/Users/yang/.npm-global/bin/qodercli’;
const CWD = process.argv[3] || os.homedir();

let requestId = 0;
const pending = new Map();

// ── Spawn + NDJSON I/O ──────────────────────────────────────────
function spawnQoder() {
const child = spawn(QODERCLI, [‘–acp’], {
stdio: [‘pipe’, ‘pipe’, ‘pipe’],
cwd: CWD,
env: { …process.env },
});

const rl = createInterface({ input: child.stdout, crlfDelay: Infinity });

child.stderr.on(‘data’, (d) => process.stderr.write([STDERR] ${d}));
child.on(‘error’, (e) => console.error(‘SPAWN ERROR:’, e.message));
child.on(‘close’, (code) => console.error([PROCESS EXIT] code=${code}));

rl.on(‘line’, (line) => {
try {
const msg = JSON.parse(line);
if (msg.id !== undefined && pending.has(msg.id)) {
const { resolve, reject, timer } = pending.get(msg.id);
clearTimeout(timer);
pending.delete(msg.id);
if (msg.error) reject(new Error(${msg.error.code}: ${msg.error.message}));
else resolve(msg.result);
} else if (msg.method) {
console.log(\n[NOTIFICATION] method=${msg.method} params=${JSON.stringify(msg.params).slice(0, 200)});
}
} catch {
/* ignore parse errors */
}
});

return { child, rl };
}

function send(child, method, params = {}) {
return new Promise((resolve, reject) => {
const id = ++requestId;
const req = JSON.stringify({ jsonrpc: ‘2.0’, method, params, id }) + ‘\n’;
const timer = setTimeout(() => {
pending.delete(id);
reject(new Error(TIMEOUT: ${method} (10s)));
}, 10_000);
pending.set(id, { resolve, reject, timer });
child.stdin.write(req);
});
}

// ── Wait for notification ────────────────────────────────────────
function waitForNotification(rl, method, timeoutMs = 10_000) {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
rl.off(‘line’, handler);
reject(new Error(TIMEOUT waiting for notification: ${method}));
}, timeoutMs);
const handler = (line) => {
try {
const msg = JSON.parse(line);
if (msg.method === method) {
clearTimeout(timer);
rl.off(‘line’, handler);
resolve(msg.params);
}
} catch { /* skip */ }
};
rl.on(‘line’, handler);
});
}

function sleep(ms) {
return new Promise((r) => setTimeout(r, ms));
}

// ── Main test flow ───────────────────────────────────────────────
async function main() {
console.log(‘═══════════════════════════════════════════════════════’);
console.log(’ qodercli ACP Session Resume Test’);
console.log( binary: ${QODERCLI});
console.log( cwd: ${CWD});
console.log(‘═══════════════════════════════════════════════════════\n’);

// ── Phase 1: Create a session ────────────────────────────────
console.log(‘── Phase 1: Spawn first process ──’);
const { child: c1, rl: rl1 } = spawnQoder();
await sleep(500); // let process start

console.log(‘\n1.1 initialize…’);
const init1 = await send(c1, ‘initialize’, {
clientInfo: { name: ‘TestScript’, version: ‘1.0.0’ },
protocolVersion: 1,
clientCapabilities: { fs: { readTextFile: true, writeTextFile: true } },
});
console.log( ✅ protocolVersion=${init1.protocolVersion});
console.log( capabilities: ${JSON.stringify(init1.agentCapabilities)});
console.log( loadSession: ${init1.agentCapabilities?.loadSession ?? 'NOT ADVERTISED'});

const supportsList = init1.agentCapabilities?.sessionCapabilities?.list !== undefined;
console.log( listSessions: ${supportsList ? 'advertised' : 'NOT ADVERTISED'});

console.log(‘\n1.2 session/new…’);
const session1 = await send(c1, ‘session/new’, {
cwd: CWD,
mcpServers: ,
additionalDirectories: ,
});
const sessionId = session1.sessionId;
console.log( ✅ sessionId = ${sessionId});

console.log(‘\n1.3 session/prompt (simple message)…’);
const promptPromise = send(c1, ‘session/prompt’, {
sessionId,
prompt: [{ type: ‘text’, text: ‘Hello, reply with exactly: ACK’ }],
});
// Wait for session/update notifications (agent response)
let responseText = ‘’;
try {
while (true) {
const update = await waitForNotification(rl1, ‘session/update’, 15_000);
if (update?.sessionUpdate === ‘agent_message_chunk’) {
responseText += update.content?.text || ‘’;
console.log( chunk: "${update.content?.text?.slice(0, 100)}");
} else if (update?.sessionUpdate === ‘agent_thought_chunk’) {
// skip thoughts
} else {
console.log( update: ${update?.sessionUpdate});
}
}
} catch (e) {
// Timeout means response is done
console.log( full response: "${responseText.slice(0, 200)}");
}
// Resolve the prompt promise
try {
await promptPromise;
console.log(’ :white_check_mark: prompt completed’);
} catch (e) {
console.log( prompt result: ${e.message});
}

// ── Phase 2: Suspend (closeSession + kill process) ───────────
console.log(‘\n── Phase 2: Suspend ──’);

console.log(‘2.1 session/close…’);
try {
const closeResult = await send(c1, ‘session/close’, { sessionId });
console.log( ✅ closeSession succeeded: ${JSON.stringify(closeResult)});
} catch (e) {
console.log( ⚠️ closeSession error: ${e.message});
}

console.log(‘2.2 Kill process…’);
c1.kill();
rl1.close();
await sleep(1000);
console.log(’ :white_check_mark: process killed’);

// ── Phase 3: Resume (new process, loadSession) ───────────────
console.log(‘\n── Phase 3: Resume ──’);
const { child: c2, rl: rl2 } = spawnQoder();
await sleep(500);

console.log(‘3.1 initialize…’);
const init2 = await send(c2, ‘initialize’, {
clientInfo: { name: ‘TestScript’, version: ‘1.0.0’ },
protocolVersion: 1,
clientCapabilities: { fs: { readTextFile: true, writeTextFile: true } },
});
console.log( loadSession: ${init2.agentCapabilities?.loadSession ?? 'NOT ADVERTISED'});

console.log(‘\n3.2 session/list (check available sessions)…’);
if (supportsList) {
try {
const listResult = await send(c2, ‘session/list’, {
cwd: CWD,
additionalDirectories: ,
cursor: null,
});
const sessions = listResult.sessions || ;
console.log( found ${sessions.length} session(s):);
for (const s of sessions) {
console.log( - id=${s.sessionId} cwd=${s.cwd} updatedAt=${s.updatedAt || 'N/A'});
}
const found = sessions.some((s) => s.sessionId === sessionId);
console.log( old session found in list: ${found ? 'YES ✅' : 'NO ❌'});
} catch (e) {
console.log( ❌ session/list error: ${e.message});
}
} else {
console.log(’ :next_track_button: skipped (agent does not advertise listSessions)');
}

console.log(‘\n3.3 session/load…’);
if (init2.agentCapabilities?.loadSession) {
try {
const loaded = await send(c2, ‘session/load’, {
sessionId,
cwd: CWD,
mcpServers: ,
additionalDirectories: ,
});
console.log( ✅ loadSession SUCCEEDED!);
console.log( loaded sessionId: ${loaded.sessionId});
console.log( loaded model: ${loaded.models?.currentModelId || 'N/A'});

  // Verify: send a prompt to the loaded session
  console.log('\n3.4 Verify loaded session (send prompt)...');
  const verifyPrompt = send(c2, 'session/prompt', {
    sessionId: loaded.sessionId,
    prompt: [{ type: 'text', text: 'What was my first message? Reply briefly.' }],
  });
  try {
    let verifyText = '';
    while (true) {
      const update = await waitForNotification(rl2, 'session/update', 10_000);
      if (update?.sessionUpdate === 'agent_message_chunk') {
        verifyText += update.content?.text || '';
        console.log(`   chunk: "${update.content?.text?.slice(0, 100)}"`);
      }
    }
  } catch {
    console.log(`   verify response: "${verifyText.slice(0, 200)}"`);
  }
  try { await verifyPrompt; } catch {}
} catch (e) {
  console.log(`   ❌ loadSession FAILED: ${e.message}`);
}

} else {
console.log(’ :next_track_button: qodercli does NOT advertise loadSession capability’);
console.log(’ → session resume is NOT supported by qodercli itself’);
}

// ── Cleanup ──────────────────────────────────────────────────
console.log(‘\n── Cleanup ──’);
c2.kill();
rl2.close();
await sleep(500);

console.log(‘\n═══════════════════════════════════════════════════’);
console.log(’ Test complete’);
console.log(‘═══════════════════════════════════════════════════’);
}

main().catch((e) => {
console.error(‘\nFATAL:’, e.message);
process.exit(1);
});