fix(discord): say when the agent has exited, keep replies in order, settle a stalled response, prune dead tabs

- a reply to a tab whose agent has exited is answered in the thread, with no tick
- inbound messages run on one chain per thread, so a reply with a file cannot be overtaken
- postJson settles when a response stalls after its headers
- the thread map is pruned against the live sessions once after boot
This commit is contained in:
Devvyn committed 2026-10-30 10:33:34 +08:00
1 parent 72a59de96c
commit b68792f988
6 files changed
+108 -4

No files matched your search

+10 -1
View File
@@ -43,7 +43,14 @@ export const postJson: JsonPost = (apiUrl, apiPath, payload, timeoutMs = REQUEST
const url = new URL(apiPath, apiUrl);
const body = JSON.stringify(payload);
const transport = url.protocol === 'https:' ? https : http;
return new Promise((resolve) => {
return new Promise((resolveOnce) => {
// Settle once: a response that stalls after its headers ends in 'close' without 'end'.
let settled = false;
const resolve: typeof resolveOnce = (v) => {
if (settled) return;
settled = true;
resolveOnce(v);
};
const headers: Record<string, string | number> = {
Accept: 'application/json',
'Content-Type': 'application/json',
@@ -64,6 +71,8 @@ export const postJson: JsonPost = (apiUrl, apiPath, payload, timeoutMs = REQUEST
(res) => {
let text = '';
res.on('data', (c) => (text += c));
res.on('error', () => resolve({ status: 0 }));
res.on('close', () => resolve({ status: 0 }));
res.on('end', () => {
let error: string | undefined;
let delivery: { ok: boolean; error?: string } | undefined;
+30 -1
View File
@@ -655,6 +655,8 @@ export interface DiscordBridgeDeps {
* Codeman types itself.
*/
sendInput: (sessionId: string, text: string, fromUser: boolean) => Promise<boolean>;
/** True once the tab's agent has exited: tmux still accepts keys for a dead pane, so a tick would lie. */
agentExited?: (sessionId: string) => boolean;
/** True while the session shows a permission or question dialog, which acts on a bare keypress. */
awaitingChoice?: (sessionId: string) => boolean;
/** A session's working folder, where received files are saved. Undefined when it is gone or remote. */
@@ -683,6 +685,8 @@ export class DiscordBridge {
private held = false;
/** Thread-map writes run one after another: concurrent creations must not interleave on the tmp file. */
private persistChain: Promise<void> = Promise.resolve();
/** Per-thread chain of inbound messages (see onGatewayMessage). */
private inbound = new Map<string, Promise<void>>();
private mapLoad?: Promise<void>;
/** The first full load (config, thread map, gateway), shared by every caller that arrives before it ends. */
private firstLoad?: Promise<void>;
@@ -711,6 +715,17 @@ export class DiscordBridge {
await this.firstLoad;
}
/** Forget thread entries for tabs that no longer exist (dropped without a delete, for example). Once, at boot. */
async prune(liveIds: Iterable<string>): Promise<void> {
await this.ensureLoaded();
const live = new Set(liveIds);
if (live.size === 0) return; // nothing restored: more likely a failed restore than an empty server
const stale = Object.keys(this.threads).filter((id) => !live.has(id));
if (stale.length === 0) return;
for (const id of stale) delete this.threads[id];
await this.persistThreads();
}
/** Hold the gateway back (boot): a reply must not arrive before the restored tabs exist. */
hold(): void {
this.held = true;
@@ -765,6 +780,7 @@ export class DiscordBridge {
this.lastReply.clear();
this.names.clear();
this.fresh.clear();
this.inbound.clear();
}
private fail(err: unknown): DiscordResult {
@@ -991,8 +1007,17 @@ export class DiscordBridge {
};
}
/** Inbound reply. Every gate here drops silently: a stranger learns nothing. */
/** Inbound reply. One chain per thread, so a reply that waits on a file download cannot be overtaken by a later one. */
async onGatewayMessage(m: GatewayMessage): Promise<void> {
const key = String(m.channel_id);
const run = (this.inbound.get(key) ?? Promise.resolve()).then(() => this.handleMessage(m)).catch(() => undefined);
this.inbound.set(key, run);
await run;
if (this.inbound.get(key) === run) this.inbound.delete(key);
}
/** Every gate here drops silently: a stranger learns nothing. */
private async handleMessage(m: GatewayMessage): Promise<void> {
if (!this.cfg.enabled || m.author?.bot) return;
if (m.guild_id !== this.cfg.guildId) return;
if (!this.cfg.allowedUserIds.includes(m.author?.id)) return;
@@ -1005,6 +1030,10 @@ export class DiscordBridge {
await this.api.react(m.channel_id, m.id, '⏳').catch(() => undefined);
return;
}
if (this.deps.agentExited?.(sessionId)) {
await this.say(m.channel_id, 'The agent in that tab has exited, so nothing was typed.');
return;
}
this.lastReply.set(sessionId, t);
const saved = await this.saveAttachments(sessionId, m);
const text = sanitiseReply(m.content ?? '', saved.paths);
+5 -1
View File
@@ -520,6 +520,7 @@ export class WebServer extends EventEmitter {
if (await session.writeViaMux(text, { fromUser }).catch(() => false)) return true;
return session.write(text, { fromUser });
},
agentExited: (id) => !!this.sessions.get(id)?.paneExit,
awaitingChoice: (id) => approvalInbox.listPending().some((item) => item.sessionId === id && item.kind !== 'idle'),
// Remote (ssh) sessions keep their folder on another host, so received files have nowhere local to go.
sessionDir: (sessionId) => {
@@ -3258,7 +3259,10 @@ export class WebServer extends EventEmitter {
if (restoredMail > 0) console.log(`[AgentInbox] restored ${restoredMail} pending message(s)`);
const restored = await this.restoreMuxSessions();
await this.finalizeRestoredState(restored);
void this.discordBridge.release().catch(() => undefined);
void this.discordBridge
.release()
.then(() => this.discordBridge.prune(this.sessions.keys()))
.catch(() => undefined);
// Instance-scoped reaper: after restore, `docker rm -f` managed containers of
// THIS instance whose case is gone from docker-cases.json (best-effort, never