mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 10:39:32 +01:00
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:
1 parent
72a59de96c
commit
b68792f988
6 files changed
+108
-4
No files matched your search
+10
-1
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user