fix(discord): discard a thread created for a replaced parent channel, test the notify timeout, document the bridge invariants

- a thread creation that finishes after the parent channel changed is dropped, and a finished creation only clears its own pending entry
- postJson takes an optional timeout, pinned by a test against a server that never answers
- drop the flat discord-bridge.json fallback
- add a Discord bridge section to architecture-invariants
This commit is contained in:
Devvyn committed 2026-10-30 06:42:17 +08:00
1 parent 1ab54b9e9b
commit dc22519adb
5 files changed
+93 -47

No files matched your search

+4 -3
View File
@@ -33,11 +33,12 @@ export interface NotifyResult {
export type JsonPost = (
apiUrl: string,
apiPath: string,
payload: unknown
payload: unknown,
timeoutMs?: number
) => Promise<{ status: number; error?: string; delivery?: { ok: boolean; error?: string } }>;
/** POST JSON to the Codeman API with the same credentials `codeman attach` uses. Status 0 means no answer. */
export const postJson: JsonPost = (apiUrl, apiPath, payload) => {
export const postJson: JsonPost = (apiUrl, apiPath, payload, timeoutMs = REQUEST_TIMEOUT_MS) => {
const { username, password } = readCodemanCredentials();
const url = new URL(apiPath, apiUrl);
const body = JSON.stringify(payload);
@@ -58,7 +59,7 @@ export const postJson: JsonPost = (apiUrl, apiPath, payload) => {
path: `${url.pathname}${url.search}`,
rejectUnauthorized: false,
headers,
timeout: REQUEST_TIMEOUT_MS,
timeout: timeoutMs,
},
(res) => {
let text = '';
+15 -14
View File
@@ -306,16 +306,14 @@ export async function writeDiscordConfig(configDir: string, cfg: DiscordConfig):
await writePrivate(discordConfigPath(configDir), coerce(cfg), 0o600);
}
/** The thread map plus the parent channel it was built under (empty for an older flat file). */
/** The thread map plus the parent channel it was built under. */
async function readThreadFile(configDir: string): Promise<{ parent: string; threads: Record<string, string> }> {
try {
const raw = JSON.parse(await fs.readFile(join(configDir, THREADS_FILE), 'utf-8')) as Record<string, unknown>;
const nested = typeof raw.threads === 'object' && raw.threads !== null;
const entries = Object.entries(nested ? (raw.threads as Record<string, unknown>) : raw);
const threads: Record<string, string> = {};
for (const [k, v] of entries) if (typeof v === 'string' && SNOWFLAKE.test(v)) threads[k] = v;
const parent = nested && typeof raw.parentChannelId === 'string' ? raw.parentChannelId : '';
return { parent, threads };
const saved = typeof raw.threads === 'object' && raw.threads !== null ? raw.threads : {};
for (const [k, v] of Object.entries(saved)) if (typeof v === 'string' && SNOWFLAKE.test(v)) threads[k] = v;
return { parent: typeof raw.parentChannelId === 'string' ? raw.parentChannelId : '', threads };
} catch {
return { parent: '', threads: {} };
}
@@ -717,10 +715,8 @@ export class DiscordBridge {
// or every tab would keep posting into the old one.
const parent = this.cfg.parentChannelId;
if (parent && parent !== this.threadsParent) {
if (this.threadsParent !== '') {
this.threads = {};
this.pending.clear();
}
this.threads = {};
this.pending.clear();
this.threadsParent = parent;
await this.persistThreads();
}
@@ -779,19 +775,24 @@ export class DiscordBridge {
if (existing) return Promise.resolve(existing);
const inflight = this.pending.get(sessionId);
if (inflight) return inflight;
const p = (async () => {
const parent = this.cfg.parentChannelId;
const mine: { p?: Promise<string | DiscordResult> } = {};
const p = (mine.p = (async () => {
try {
const t = await this.api.createThread(this.cfg.parentChannelId, threadNameFor(name, sessionId));
const t = await this.api.createThread(parent, threadNameFor(name, sessionId));
if (!t?.id) return this.fail(new Error('Discord did not return a thread'));
// A save moved the bridge to another channel meanwhile: this thread belongs to the old one.
if (parent !== this.cfg.parentChannelId) return this.fail(new Error('The parent channel changed'));
this.threads[sessionId] = t.id;
await this.persistThreads();
return t.id;
} catch (err) {
return this.fail(err);
} finally {
this.pending.delete(sessionId);
// Only this creation's own entry: a newer one for the same tab may have replaced it.
if (this.pending.get(sessionId) === mine.p) this.pending.delete(sessionId);
}
})();
})());
this.pending.set(sessionId, p);
return p;
}