feat: pet chat window replaces the Sagi panel; Windows pane attach no longer crashes the server
- Sagi: clicking the pet opens its chat window directly (PetChat + SagiInfo status strip, Needs you, Mute / Clear alerts); SagiPanel removed. - Chat view answers Claude's unnumbered folder-trust prompt (parseBareMenu). - pty-attach: resize/write after a Windows attach exited, and node-pty's pipe errors, no longer throw out of the WebSocket handler. - restart-helper: the server started by an update logs to data/agenthub-server.log instead of discarding its output.
This commit is contained in:
@@ -204,6 +204,45 @@ describe("pty-attach", () => {
|
||||
assert.deepEqual(JSON.parse(a.__sent.at(-1).data), { type: "lead" });
|
||||
});
|
||||
|
||||
it("survives a stopped pane: pipe errors, typing and resizes after the attach exited", () => {
|
||||
// Windows node-pty re-throws a read-pipe error nobody listens for, and its
|
||||
// write pipe has no listener at all: Stop (kill-session) ended the attach
|
||||
// with EPIPE and the uncaught error killed the whole dashboard.
|
||||
const ptys = [];
|
||||
ptyAttach.__setSpawnImpl(() => {
|
||||
const p = makeFakePty();
|
||||
p.on = (evt, fn) => p.__emitter.on(evt, fn);
|
||||
p._agent = { inSocket: new EventEmitter() };
|
||||
p.resize = () => {
|
||||
if (p.__gone) throw new Error("Cannot resize a pty that has already exited");
|
||||
};
|
||||
p.write = () => {
|
||||
if (p.__gone) throw new Error("write EPIPE");
|
||||
};
|
||||
ptys.push(p);
|
||||
return p;
|
||||
});
|
||||
const a = makeFakeWs();
|
||||
const b = makeFakeWs();
|
||||
a.close = b.close = () => {};
|
||||
ptyAttach.attach(a, "agenthub-lane-1", { cols: 80, rows: 24 });
|
||||
ptyAttach.attach(b, "agenthub-lane-1", { cols: 80, rows: 24 });
|
||||
|
||||
const epipe = Object.assign(new Error("read EPIPE"), { code: "EPIPE" });
|
||||
assert.doesNotThrow(() => ptys[0].__emitter.emit("error", epipe));
|
||||
assert.doesNotThrow(() => ptys[0]._agent.inSocket.emit("error", epipe));
|
||||
|
||||
ptys[0].__gone = true;
|
||||
ptys[0].__emitter.emit("exit", { exitCode: 1 }); // a's close event not yet in
|
||||
const msg = (ws, data, binary) => ws.__emitter.emit("message", Buffer.from(data), { binary });
|
||||
assert.doesNotThrow(() =>
|
||||
msg(b, JSON.stringify({ type: "resize", cols: 90, rows: 30, drive: true }), false)
|
||||
);
|
||||
assert.doesNotThrow(() => msg(a, "x", true));
|
||||
ptys[1].__gone = true; // exited, onExit still queued
|
||||
assert.doesNotThrow(() => msg(b, "y", true));
|
||||
});
|
||||
|
||||
it("kills the PTY attach process when the WS connection closes", () => {
|
||||
const ws = makeFakeWs();
|
||||
ptyAttach.attach(ws, "agenthub-lane-1", { cols: 80, rows: 24 });
|
||||
|
||||
@@ -3,12 +3,17 @@
|
||||
* @description After an update, a server our service unit runs is restarted
|
||||
* by the unit, never by a detached replacement: v2.7.2 on the S3 system unit
|
||||
* left the replacement holding the port while `Restart=always` started the
|
||||
* unit every 3 s, each start exiting "already running".
|
||||
* unit every 3 s, each start exiting "already running". Without a unit, the
|
||||
* detached helper's replacement server logs to `data/agenthub-server.log`.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { execFileSync } = require("node:child_process");
|
||||
const { scheduleRestart, owningUnit } = require("../lib/self-restart");
|
||||
|
||||
function deps(unit, restartAnswer = "restarted") {
|
||||
@@ -23,8 +28,9 @@ function deps(unit, restartAnswer = "restarted") {
|
||||
},
|
||||
}),
|
||||
kill: () => calls.kill++,
|
||||
spawnHelper: () => {
|
||||
spawnHelper: (_exe, args) => {
|
||||
calls.spawn++;
|
||||
calls.helperArgs = args;
|
||||
return { unref() {} };
|
||||
},
|
||||
};
|
||||
@@ -84,5 +90,27 @@ describe("self-restart", () => {
|
||||
assert.equal(scheduleRestart("/r", d), "helper");
|
||||
assert.equal(d.calls.spawn, 1);
|
||||
assert.equal(d.calls.kill, 1);
|
||||
// The new server logs where `agenthub start` does — never into nothing.
|
||||
assert.equal(d.calls.helperArgs.at(-1), path.join("/r", "data", "agenthub-server.log"));
|
||||
});
|
||||
|
||||
it("restart-helper appends the new server's stdout and stderr to the log", async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "restart-helper-"));
|
||||
const entry = path.join(dir, "entry.js");
|
||||
const log = path.join(dir, "data", "agenthub-server.log");
|
||||
fs.writeFileSync(entry, 'console.log("up"); throw new Error("boom");\n');
|
||||
const helper = path.join(__dirname, "..", "..", "scripts", "restart-helper.js");
|
||||
// pid 0x7fffffff is never alive, so the helper restarts at once.
|
||||
execFileSync(process.execPath, [helper, "2147483647", entry, log]);
|
||||
const deadline = Date.now() + 5000;
|
||||
let text = "";
|
||||
while (Date.now() < deadline && !/boom/.test(text)) {
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
text = fs.existsSync(log) ? fs.readFileSync(log, "utf8") : "";
|
||||
}
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
assert.match(text, /restart-helper: old server 2147483647 gone/);
|
||||
assert.match(text, /^up$/m);
|
||||
assert.match(text, /Error: boom/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -84,17 +84,49 @@ const tell = (v, msg) => {
|
||||
}
|
||||
};
|
||||
|
||||
// node-pty throws from resize() once its process has exited ("Cannot resize a
|
||||
// pty that has already exited" on Windows), and a viewer's attach can exit
|
||||
// before its socket's close event takes it out of the group — a throw here,
|
||||
// inside a WebSocket message handler, would take the whole server down.
|
||||
const resize = (v, cols, rows) => {
|
||||
if (v.exited) return;
|
||||
try {
|
||||
v.pty.resize(cols, rows);
|
||||
} catch {
|
||||
/* attach already gone — its exit/close handlers clean up */
|
||||
}
|
||||
};
|
||||
|
||||
/** `v` sizes the window now: every other attach is held at its size and told. */
|
||||
function drive(group, v) {
|
||||
group.driver = v;
|
||||
v.pty.resize(v.cols, v.rows);
|
||||
resize(v, v.cols, v.rows);
|
||||
for (const o of group.viewers) {
|
||||
if (o === v) continue;
|
||||
o.pty.resize(v.cols, v.rows);
|
||||
resize(o, v.cols, v.rows);
|
||||
tell(o, { type: "size", cols: v.cols, rows: v.rows });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Windows node-pty re-throws any read-pipe error that is not EIO unless a
|
||||
* second `error` listener exists, and its write pipe has no listener at all —
|
||||
* so the EPIPE/ECONNRESET a pane's attach raises when the session is stopped
|
||||
* (Stop, kill-session) became an uncaught exception that killed the dashboard.
|
||||
* The attach's exit is reported by `onExit`; the error itself carries nothing.
|
||||
*/
|
||||
function swallowPipeErrors(pty) {
|
||||
const ignore = () => {};
|
||||
try {
|
||||
pty.on?.("error", ignore);
|
||||
} catch {
|
||||
/* no event emitter on this pty — nothing to guard */
|
||||
}
|
||||
// Private on node-pty's Windows agent; absent elsewhere and in tests.
|
||||
const input = pty._agent?.inSocket;
|
||||
if (input && typeof input.on === "function") input.on("error", ignore);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach `ws` to the tmux session `runId`. Spawns one PTY-backed
|
||||
* `tmux attach-session -t <runId>` per call.
|
||||
@@ -132,7 +164,8 @@ function attach(ws, runId, { cols, rows, platform = process.platform }) {
|
||||
// newer one VS Code uses for the same reason. Ignored elsewhere.
|
||||
...(platform === "win32" && { useConptyDll: true }),
|
||||
});
|
||||
const me = { ws, pty, cols: cols || 80, rows: rows || 24 };
|
||||
swallowPipeErrors(pty);
|
||||
const me = { ws, pty, cols: cols || 80, rows: rows || 24, exited: false };
|
||||
group.viewers.add(me);
|
||||
if (lead) tell(me, { type: "size", cols: lead.cols, rows: lead.rows });
|
||||
else group.driver = me;
|
||||
@@ -179,16 +212,22 @@ function attach(ws, runId, { cols, rows, platform = process.platform }) {
|
||||
}
|
||||
};
|
||||
function write(text) {
|
||||
if (me.exited) return; // the attach is gone: nothing reads this pipe
|
||||
const wait = holdUntil - Date.now();
|
||||
if (held.length || wait > 0) {
|
||||
if (held.push(text) === 1) setTimeout(flush, Math.max(0, wait));
|
||||
return;
|
||||
}
|
||||
pty.write(text);
|
||||
try {
|
||||
pty.write(text);
|
||||
} catch {
|
||||
return; // exited between the check and the write — onExit follows
|
||||
}
|
||||
if (holdsPaste && text.includes("\x1b[201~")) holdUntil = Date.now() + PASTE_HOLD_MS;
|
||||
}
|
||||
|
||||
pty.onExit(({ exitCode }) => {
|
||||
me.exited = true;
|
||||
try {
|
||||
ws.send(JSON.stringify({ type: "exit", code: exitCode }), { binary: false });
|
||||
} catch {
|
||||
|
||||
@@ -77,7 +77,10 @@ function scheduleRestart(root = path.join(__dirname, "..", ".."), deps = {}) {
|
||||
}
|
||||
const helperPath = path.join(root, "scripts", "restart-helper.js");
|
||||
const entry = process.argv[1] || path.join(root, "server", "index.js");
|
||||
const helper = spawnHelper(process.execPath, [helperPath, String(process.pid), entry], {
|
||||
// The same log `agenthub start` writes: without it every server started by
|
||||
// an update ran with no stdout/stderr at all, and a crash left no trace.
|
||||
const logFile = path.join(root, "data", "agenthub-server.log");
|
||||
const helper = spawnHelper(process.execPath, [helperPath, String(process.pid), entry, logFile], {
|
||||
cwd: root,
|
||||
detached: true,
|
||||
stdio: "ignore",
|
||||
|
||||
Reference in New Issue
Block a user