// opencode 直连 shell 派发器(R115 修正 B-113 误判:session 路由 204 是设计行为,不是失败) // 关键事实(openapi.json 实证): // 1. POST /api/shell?location[directory]= ← 正确入口,直接返回 Shell.Info(含 id) // 2. POST /api/session/{id}/shell 恒返 204(无 body),且 shell 退出后即被 GC // → 旧脚本轮询 /api/shell(list 只列 running)必然拿到 NONE,误判为「建不出对象」 // 3. 长任务必须「重定向到日志文件 + 另起 shell 读文件」,否则 shell 对象被回收后 // /api/shell/{id}/output 返回 ShellNotFoundError // 用法(Git Bash 必须带 MSYS_NO_PATHCONV=1,否则 /sc-data 会被改写成本机路径): // MSYS_NO_PATHCONV=1 BASE=http://192.168.16.251:4096 DIR=/sc-data/cfc/cfc-langgraph \ // CMD="pytest -q" LOG=/tmp/x.log node dispatch_shell.cjs const BASE = process.env.BASE; const DIR = process.env.DIR; const CMD = process.env.CMD; const LOG = process.env.LOG || '/tmp/dispatch_shell.log'; const CAP_MS = parseInt(process.env.CAP_MS || '900000', 10); const AUTH = 'Basic ' + Buffer.from('opencode:' + (process.env.OPENCODE_SERVER_PASSWORD || 'IwinTrue@123')).toString('base64'); const H = { Authorization: AUTH, 'Content-Type': 'application/json' }; const LOC = '?location%5Bdirectory%5D=' + encodeURIComponent(DIR || ''); async function j(url, opt) { try { const r = await fetch(url, Object.assign({ headers: H }, opt)); const t = await r.text(); try { return { status: r.status, json: JSON.parse(t) }; } catch { return { status: r.status, text: t }; } } catch (e) { return { status: 0, err: e.message }; } } const sleep = ms => new Promise(r => setTimeout(r, ms)); async function run(cmd, capMs) { const cr = await j(BASE + '/api/shell' + LOC, { method: 'POST', body: JSON.stringify({ command: cmd, cwd: DIR }) }); const id = cr.json && cr.json.data && cr.json.data.id; if (!id) return { err: 'create failed ' + cr.status + ' ' + JSON.stringify(cr).slice(0, 200) }; const t0 = Date.now(); while (Date.now() - t0 < capMs) { await sleep(3000); const s = await j(BASE + '/api/shell/' + id + LOC); const d = (s.json && s.json.data) || null; if (!d) continue; // GC/竞态:继续等下一次 if (d.status && d.status !== 'running') { const o = await j(BASE + '/api/shell/' + id + '/output' + LOC + '&limit=200000'); return { status: d.status, out: (o.json && o.json.data && o.json.data.output) || '' }; } } return { err: 'timeout ' + capMs + 'ms' }; } (async () => { // 1) 后台启动长任务并写盘(避免 shell 对象被回收) const kick = await run('rm -f ' + LOG + '; ( ' + CMD + ' ) > ' + LOG + ' 2>&1; echo DONE_$? >> ' + LOG, 30000); console.log('KICK', kick.err || kick.status); // 2) 轮询日志文件 const t0 = Date.now(); let tail = ''; while (Date.now() - t0 < CAP_MS) { await sleep(8000); const r = await run('tail -60 ' + LOG, 45000); if (r.err) { console.log('POLL_ERR', r.err); continue; } tail = r.out || ''; if (/DONE_\d+/.test(tail)) break; } console.log('OUT_BEGIN'); console.log(tail); console.log('OUT_END'); })().catch(e => { console.error('ERR', e.message); process.exit(1); });