From 5b16bb66944a4cd684400cfc5bd06acfe9f1f224 Mon Sep 17 00:00:00 2001 From: claude-bot Date: Mon, 1 Jun 2026 17:25:34 +0900 Subject: [PATCH 01/11] feat(primer): add usage-window alignment primer on GitHub-main base Port the local primer subsystem onto the upstream base by intent: - add src/usage-primer.ts (KST-slot primer firing Claude + Codex signals) - extend codex-warmup with the ignoreZeroUsageWindow runtime option used by the primer so a slot is never skipped just because Codex is partway through its weekly window - Codex primer fires unconditionally (maxUsagePct/maxD7UsagePct=100), mirroring the Claude primer, refreshing usage before selection - wire startUsagePrimer() into the runtime bootstrap Co-Authored-By: Claude Opus 4 --- src/codex-warmup.test.ts | 65 ++++++++++++ src/codex-warmup.ts | 23 +++-- src/index.ts | 2 + src/usage-primer.test.ts | 77 ++++++++++++++ src/usage-primer.ts | 212 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 373 insertions(+), 6 deletions(-) create mode 100644 src/usage-primer.test.ts create mode 100644 src/usage-primer.ts diff --git a/src/codex-warmup.test.ts b/src/codex-warmup.test.ts index b0c4b0c..13e4a90 100644 --- a/src/codex-warmup.test.ts +++ b/src/codex-warmup.test.ts @@ -225,6 +225,71 @@ describe('Codex warm-up scheduler', () => { expect(childProcess.spawn).not.toHaveBeenCalled(); }); + it('lets the fixed-slot primer warm an account again when 5h usage is fresh but weekly usage is nonzero', async () => { + const childProcess = await import('child_process'); + const rotation = await import('./codex-token-rotation.js'); + const { runCodexWarmupCycle } = await import('./codex-warmup.js'); + const now = new Date('2026-04-24T14:00:00Z').getTime(); + + fs.writeFileSync( + statePath, + JSON.stringify({ + lastWarmupAt: '2026-04-24T09:00:00.000Z', + consecutiveFailures: 0, + accounts: { + '0': { + lastWarmupAt: '2026-04-24T09:00:00.000Z', + zeroUsageWarmupUntil: '2026-05-01T09:00:00.000Z', + }, + }, + }), + ); + vi.mocked(rotation.getAllCodexAccounts).mockReturnValue([ + { + index: 0, + accountId: 'same-account-next-slot', + planType: 'pro', + isActive: true, + isRateLimited: false, + cachedUsagePct: 0, + cachedUsageD7Pct: 37, + resetAt: '2026-04-24T19:00:00.000Z', + resetD7At: '2026-05-01T09:00:00.000Z', + }, + ]); + vi.mocked(rotation.getCodexAuthPath).mockImplementation( + (accountIndex = 0) => authPathFor(tempHome, accountIndex), + ); + vi.mocked(childProcess.spawn).mockImplementation( + () => createFakeCodexProcess(0) as never, + ); + + const result = await runCodexWarmupCycle( + { + enabled: true, + prompt: 'Reply exactly OK. Do not run tools.', + model: 'gpt-5.5', + intervalMs: 300_000, + minIntervalMs: 18_000_000, + staggerMs: 0, + maxUsagePct: 0, + maxD7UsagePct: 100, + commandTimeoutMs: 120_000, + failureCooldownMs: 21_600_000, + maxConsecutiveFailures: 2, + }, + { nowMs: now, statePath, ignoreZeroUsageWindow: true }, + ); + + expect(result).toEqual({ status: 'warmed', accountIndex: 0 }); + expect(childProcess.spawn).toHaveBeenCalledTimes(1); + const state = JSON.parse(fs.readFileSync(statePath, 'utf8')); + expect(state.accounts['0'].lastWarmupAt).toBe('2026-04-24T14:00:00.000Z'); + expect(state.accounts['0'].zeroUsageWarmupUntil).toBe( + '2026-04-24T14:00:00.000Z', + ); + }); + it('auto-backs off after repeated codex exec failures so OpenAI-side blocking does not hammer accounts', async () => { const childProcess = await import('child_process'); const rotation = await import('./codex-token-rotation.js'); diff --git a/src/codex-warmup.ts b/src/codex-warmup.ts index a70b58a..5c2ba11 100644 --- a/src/codex-warmup.ts +++ b/src/codex-warmup.ts @@ -33,6 +33,7 @@ interface CodexWarmupRuntimeOptions { nowMs?: number; statePath?: string; shouldSkip?: () => boolean; + ignoreZeroUsageWindow?: boolean; } export type CodexWarmupCycleResult = @@ -105,6 +106,7 @@ function selectWarmupCandidate( config: CodexWarmupConfig, state: CodexWarmupState, nowMs: number, + options: { ignoreZeroUsageWindow?: boolean } = {}, ): { accountIndex: number; zeroUsageWarmupUntil: string } | { reason: string } { const disabledUntilMs = parseTimestamp(state.disabledUntil); if (disabledUntilMs != null && disabledUntilMs > nowMs) { @@ -134,7 +136,11 @@ function selectWarmupCandidate( const zeroUsageWarmupUntilMs = parseTimestamp( accountState?.zeroUsageWarmupUntil, ); - if (zeroUsageWarmupUntilMs != null && zeroUsageWarmupUntilMs > nowMs) { + if ( + !options.ignoreZeroUsageWindow && + zeroUsageWarmupUntilMs != null && + zeroUsageWarmupUntilMs > nowMs + ) { continue; } @@ -144,10 +150,13 @@ function selectWarmupCandidate( } const resetD7Ms = parseTimestamp(account.resetD7At); - const zeroUsageWarmupUntilMsForState = - resetD7Ms != null && resetD7Ms > nowMs - ? resetD7Ms - : nowMs + DEFAULT_ZERO_USAGE_WARMUP_WINDOW_MS; + let zeroUsageWarmupUntilMsForState = nowMs; + if (!options.ignoreZeroUsageWindow) { + zeroUsageWarmupUntilMsForState = + resetD7Ms != null && resetD7Ms > nowMs + ? resetD7Ms + : nowMs + DEFAULT_ZERO_USAGE_WARMUP_WINDOW_MS; + } return { accountIndex: account.index, @@ -254,7 +263,9 @@ export async function runCodexWarmupCycle( const nowIso = new Date(nowMs).toISOString(); const statePath = runtime.statePath ?? DEFAULT_STATE_FILE; const state = readWarmupState(statePath); - const selected = selectWarmupCandidate(config, state, nowMs); + const selected = selectWarmupCandidate(config, state, nowMs, { + ignoreZeroUsageWindow: runtime.ignoreZeroUsageWindow, + }); if ('reason' in selected) { return { status: 'skipped', reason: selected.reason }; } diff --git a/src/index.ts b/src/index.ts index af887ff..47282a6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -54,6 +54,7 @@ import { createMessageRuntime } from './message-runtime.js'; import { nudgeSchedulerLoop, startSchedulerLoop } from './task-scheduler.js'; import { startUnifiedDashboard } from './unified-dashboard.js'; import { startWebDashboardServer } from './web-dashboard-server.js'; +import { startUsagePrimer } from './usage-primer.js'; import { Channel, NewMessage, RegisteredGroup } from './types.js'; import { logger } from './logger.js'; import { initCodexTokenRotation } from './codex-token-rotation.js'; @@ -575,6 +576,7 @@ async function main(): Promise { queue.enqueueMessageCheck(chatJid, resolveGroupIpcPath(groupFolder)), nudgeScheduler: nudgeSchedulerLoop, }); + startUsagePrimer(); leaseRecoveryTimer = setInterval(() => { const failover = getGlobalFailoverInfo(); diff --git a/src/usage-primer.test.ts b/src/usage-primer.test.ts new file mode 100644 index 0000000..18e649b --- /dev/null +++ b/src/usage-primer.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it, vi } from 'vitest'; + +const callOrder: string[] = []; + +const refreshAllCodexAccountUsage = vi.fn(async () => { + callOrder.push('refreshAll'); + return { rows: [], fetchedAt: '2026-06-01T00:00:00.000Z' }; +}); +const refreshActiveCodexUsage = vi.fn(async () => { + callOrder.push('refreshActive'); + return { rows: [], fetchedAt: '2026-06-01T00:00:01.000Z' }; +}); +const runCodexWarmupCycle = vi.fn(async () => { + callOrder.push('warmup'); + return { status: 'warmed', accountIndex: 0 }; +}); + +vi.mock('./config.js', () => ({ + CODEX_WARMUP_CONFIG: { + enabled: false, + prompt: 'Reply exactly OK. Do not run tools.', + model: 'gpt-5.5', + intervalMs: 300_000, + minIntervalMs: 18_300_000, + staggerMs: 1_800_000, + maxUsagePct: 0, + maxD7UsagePct: 0, + commandTimeoutMs: 120_000, + failureCooldownMs: 21_600_000, + maxConsecutiveFailures: 2, + }, +})); + +vi.mock('./logger.js', () => ({ + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +vi.mock('./token-rotation.js', () => ({ + getAllTokens: vi.fn(() => []), +})); + +vi.mock('./codex-usage-collector.js', () => ({ + refreshAllCodexAccountUsage, + refreshActiveCodexUsage, +})); + +vi.mock('./codex-warmup.js', () => ({ + runCodexWarmupCycle, +})); + +describe('usage-primer', () => { + it('refreshes Codex usage before primer selection and fires regardless of usage level', async () => { + callOrder.length = 0; + refreshAllCodexAccountUsage.mockClear(); + refreshActiveCodexUsage.mockClear(); + runCodexWarmupCycle.mockClear(); + + const { runCodexPrimerCycle } = await import('./usage-primer.js'); + await runCodexPrimerCycle(); + + expect(callOrder).toEqual(['refreshAll', 'refreshActive', 'warmup']); + expect(runCodexWarmupCycle).toHaveBeenCalledWith( + expect.objectContaining({ + enabled: true, + minIntervalMs: 18_000_000, + maxUsagePct: 100, + maxD7UsagePct: 100, + }), + { ignoreZeroUsageWindow: true }, + ); + }); +}); diff --git a/src/usage-primer.ts b/src/usage-primer.ts new file mode 100644 index 0000000..fad904a --- /dev/null +++ b/src/usage-primer.ts @@ -0,0 +1,212 @@ +import { spawn } from 'child_process'; +import fs from 'fs'; +import path from 'path'; + +import { CODEX_WARMUP_CONFIG } from './config.js'; +import { + refreshActiveCodexUsage, + refreshAllCodexAccountUsage, +} from './codex-usage-collector.js'; +import { runCodexWarmupCycle } from './codex-warmup.js'; +import { logger } from './logger.js'; +import { getAllTokens } from './token-rotation.js'; + +/** + * Usage-window alignment primer. + * + * Anthropic/OpenAI enforce 5h rolling usage windows that start on first + * inference call. We anchor windows to fixed KST times by firing a tiny + * primer call at 08, 13, 18, 23 KST (4 windows ร— 5h = 20h, leaving a + * 04:00โ€“08:00 gap blocked by message-runtime-gating). + * + * Daily reset point ends up being 08:00 KST (start of first slot after gap). + */ + +const PRIMER_HOURS_KST = [8, 13, 18, 23]; +const KST_OFFSET_MS = 9 * 60 * 60 * 1000; +const CLAUDE_PRIMER_TIMEOUT_MS = 60_000; +const CODEX_PRIMER_MIN_INTERVAL_MS = 5 * 60 * 60 * 1000; +// Fire the Codex primer unconditionally at every slot, mirroring the Claude +// primer (which always fires unless the account is rate-limited). The 5h usage +// level no longer gates the call (maxUsagePct=100), so a slot is never skipped +// just because Codex happens to be partway through its window. The d7 gate +// stays at 100 so we still skip an account whose weekly quota is exhausted, +// which is the Codex analogue of Claude's rate-limit skip. +const CODEX_PRIMER_MAX_USAGE_PCT = 100; +const CODEX_PRIMER_MAX_D7_USAGE_PCT = 100; + +function resolveClaudeBinary(): string { + const candidates = [ + path.resolve( + process.cwd(), + 'runners/agent-runner/node_modules/@anthropic-ai/claude-agent-sdk-linux-x64/claude', + ), + path.resolve( + process.cwd(), + 'runners/agent-runner/node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl/claude', + ), + ]; + for (const p of candidates) { + if (fs.existsSync(p)) return p; + } + return candidates[0]; +} + +function runClaudePrimerOnce( + token: string, +): Promise<{ ok: boolean; reason: string }> { + const binary = resolveClaudeBinary(); + if (!fs.existsSync(binary)) { + return Promise.resolve({ ok: false, reason: 'binary_missing' }); + } + const args = [ + '-p', + 'ok', + '--model', + 'haiku', + '--output-format', + 'text', + '--max-turns', + '1', + '--dangerously-skip-permissions', + ]; + + return new Promise((resolve) => { + let done = false; + let stderr = ''; + const finish = (result: { ok: boolean; reason: string }): void => { + if (done) return; + done = true; + clearTimeout(timer); + resolve(result); + }; + const timer = setTimeout(() => { + try { + proc.kill(); + } catch { + /* ignore */ + } + finish({ ok: false, reason: 'timeout' }); + }, CLAUDE_PRIMER_TIMEOUT_MS); + + const proc = spawn(binary, args, { + stdio: ['ignore', 'pipe', 'pipe'], + env: { + ...process.env, + CLAUDE_CODE_OAUTH_TOKEN: token, + }, + }); + proc.stderr?.on('data', (chunk: Buffer) => { + stderr += chunk.toString(); + if (stderr.length > 2000) stderr = stderr.slice(-2000); + }); + proc.on('error', (err) => { + logger.warn({ err }, 'Claude primer spawn error'); + finish({ ok: false, reason: 'spawn_error' }); + }); + proc.on('close', (code) => { + if (code === 0) { + finish({ ok: true, reason: 'ok' }); + } else { + logger.warn( + { exitCode: code, stderr: stderr.trim().slice(-500) || undefined }, + 'Claude primer command failed', + ); + finish({ ok: false, reason: `exit_${code ?? 'unknown'}` }); + } + }); + }); +} + +async function runClaudePrimerCycle(): Promise { + const tokens = getAllTokens(); + const candidates = tokens.filter((t) => !t.isRateLimited); + if (candidates.length === 0) { + logger.info('Claude primer skipped: no eligible tokens'); + return; + } + const target = candidates[0]; + logger.info( + { tokenIndex: target.index }, + 'Starting Claude primer (haiku, prompt=ok)', + ); + const result = await runClaudePrimerOnce(target.token); + if (result.ok) { + logger.info({ tokenIndex: target.index }, 'Claude primer completed'); + } else { + logger.warn( + { tokenIndex: target.index, reason: result.reason }, + 'Claude primer failed', + ); + } +} + +export async function runCodexPrimerCycle(): Promise { + try { + await refreshAllCodexAccountUsage(); + await refreshActiveCodexUsage(); + const result = await runCodexWarmupCycle( + { + ...CODEX_WARMUP_CONFIG, + enabled: true, + minIntervalMs: CODEX_PRIMER_MIN_INTERVAL_MS, + maxUsagePct: CODEX_PRIMER_MAX_USAGE_PCT, + maxD7UsagePct: CODEX_PRIMER_MAX_D7_USAGE_PCT, + }, + { ignoreZeroUsageWindow: true }, + ); + logger.info({ result }, 'Codex primer cycle finished'); + } catch (err) { + logger.warn({ err }, 'Codex primer cycle threw'); + } +} + +async function runPrimerCycle(): Promise { + logger.info('Running scheduled usage primer cycle'); + await Promise.allSettled([runClaudePrimerCycle(), runCodexPrimerCycle()]); +} + +export function msUntilNextPrimerSlotKST(nowMs: number = Date.now()): number { + // KST = UTC+9 (no DST). Shift so KST clock can be read via getUTC*. + const kstNowShifted = nowMs + KST_OFFSET_MS; + const kstDate = new Date(kstNowShifted); + const kstH = kstDate.getUTCHours(); + + let nextH = PRIMER_HOURS_KST.find((h) => h > kstH); + let dayOffset = 0; + if (nextH === undefined) { + nextH = PRIMER_HOURS_KST[0]; + dayOffset = 1; + } + + const target = new Date(kstNowShifted); + target.setUTCHours(nextH, 0, 0, 0); + if (dayOffset === 1) target.setUTCDate(target.getUTCDate() + 1); + + return target.getTime() - kstNowShifted; +} + +let primerTimer: ReturnType | null = null; + +export function startUsagePrimer(): void { + if (primerTimer) return; + const scheduleNext = (): void => { + const delay = msUntilNextPrimerSlotKST(); + primerTimer = setTimeout(() => { + void runPrimerCycle().finally(scheduleNext); + }, delay); + const fireAt = new Date(Date.now() + delay).toISOString(); + logger.info( + { delayMinutes: Math.round(delay / 60_000), fireAt }, + 'Next usage primer scheduled', + ); + }; + scheduleNext(); +} + +export function stopUsagePrimer(): void { + if (primerTimer) { + clearTimeout(primerTimer); + primerTimer = null; + } +} From 5f21c27c66d4e5fc66727ddfeea95a6530de858d Mon Sep 17 00:00:00 2001 From: claude-bot Date: Mon, 1 Jun 2026 17:27:44 +0900 Subject: [PATCH 02/11] fix(discord): preserve fenced code blocks across 2000-char chunks Replace the naive byte-slice chunk loop with a fence-aware chunkForDiscord() splitter: prefers newline boundaries, re-closes any open ``` fence at the end of a chunk and reopens it with the same language tag at the start of the next, and is surrogate-pair safe. Adds 10 unit tests for the seam, multi-fence documents, surrogate pairs, and single-line overflow. Co-Authored-By: Claude Opus 4 --- src/channels/discord.test.ts | 128 ++++++++++++++++++++++++++++++++++- src/channels/discord.ts | 117 +++++++++++++++++++++++++++++++- 2 files changed, 241 insertions(+), 4 deletions(-) diff --git a/src/channels/discord.test.ts b/src/channels/discord.test.ts index b1e4aaa..438a9aa 100644 --- a/src/channels/discord.test.ts +++ b/src/channels/discord.test.ts @@ -129,7 +129,11 @@ vi.mock('discord.js', () => { }; }); -import { DiscordChannel, DiscordChannelOpts } from './discord.js'; +import { + DiscordChannel, + DiscordChannelOpts, + chunkForDiscord, +} from './discord.js'; import { logger } from '../logger.js'; // --- Test helpers --- @@ -1138,3 +1142,125 @@ describe('channel properties', () => { expect(channel.name).toBe('discord'); }); }); + +// --- chunkForDiscord --- + +describe('chunkForDiscord', () => { + it('returns the input unchanged when within the limit', () => { + const text = 'short message'; + expect(chunkForDiscord(text, 2000)).toEqual([text]); + }); + + it('returns empty array for empty input', () => { + expect(chunkForDiscord('', 2000)).toEqual([]); + }); + + it('splits on newline boundaries when possible', () => { + const text = 'aaa\nbbb\nccc\nddd'; + const chunks = chunkForDiscord(text, 8); + // Every chunk must be โ‰ค 8 chars and joining them must rebuild the text + for (const c of chunks) expect(c.length).toBeLessThanOrEqual(8); + expect(chunks.join('')).toBe(text); + }); + + it('reopens a fenced code block across chunks (with language)', () => { + const code = Array.from({ length: 200 }, (_, i) => `line ${i}`).join('\n'); + const text = 'intro paragraph.\n\n```ts\n' + code + '\n```\nafter'; + const chunks = chunkForDiscord(text, 500); + expect(chunks.length).toBeGreaterThan(1); + for (const c of chunks) expect(c.length).toBeLessThanOrEqual(500); + // Every chunk must have a balanced number of ``` fences (so it renders + // as valid markdown on its own). + for (const c of chunks) { + const fenceMatches = c.match(/^```/gm) ?? []; + expect(fenceMatches.length % 2).toBe(0); + } + // The middle chunks must reopen the same language. + for (let i = 1; i < chunks.length; i++) { + // It should either start with the reopened fence, or never have been + // inside the fence at this point. + if (chunks[i].startsWith('```')) { + expect(chunks[i].startsWith('```ts\n')).toBe(true); + } + } + }); + + it('reopens a language-less fenced code block', () => { + const code = Array.from({ length: 200 }, (_, i) => `payload-${i}`).join( + '\n', + ); + const text = '```\n' + code + '\n```'; + const chunks = chunkForDiscord(text, 400); + expect(chunks.length).toBeGreaterThan(1); + for (const c of chunks) { + const fenceMatches = c.match(/^```/gm) ?? []; + expect(fenceMatches.length % 2).toBe(0); + } + // Joining back should rebuild a string whose un-fenced content equals + // the original code (allowing for extra fence pairs in the middle). + const reconstructed = chunks + .join('\n') + .replace(/```\n```\n/g, '') // drop reopen-close pairs at chunk seams + .replace(/```\n```$/g, ''); + expect(reconstructed.includes('payload-0')).toBe(true); + expect(reconstructed.includes('payload-199')).toBe(true); + }); + + it('handles multiple fenced blocks in the same document', () => { + const blockA = Array.from({ length: 50 }, () => 'AAAA').join('\n'); + const blockB = Array.from({ length: 50 }, () => 'BBBB').join('\n'); + const text = + 'intro\n```js\n' + blockA + '\n```\nbetween\n```py\n' + blockB + '\n```'; + const chunks = chunkForDiscord(text, 200); + for (const c of chunks) expect(c.length).toBeLessThanOrEqual(200); + for (const c of chunks) { + const fenceMatches = c.match(/^```/gm) ?? []; + expect(fenceMatches.length % 2).toBe(0); + } + }); + + it('does not split a surrogate pair', () => { + // Emoji ๐Ÿฆ„ is a surrogate pair (length 2 in JS). + const emoji = '๐Ÿฆ„'; + const text = emoji.repeat(50); + const chunks = chunkForDiscord(text, 9); + for (const c of chunks) { + // No chunk should start or end mid surrogate pair. + expect(c.charCodeAt(0) >= 0xdc00 && c.charCodeAt(0) <= 0xdfff).toBe(false); + const last = c.charCodeAt(c.length - 1); + expect(last >= 0xd800 && last <= 0xdbff).toBe(false); + } + expect(chunks.join('')).toBe(text); + }); + + it('falls back gracefully when a single line exceeds maxLength', () => { + const longLine = 'x'.repeat(5000); + const chunks = chunkForDiscord(longLine, 2000); + for (const c of chunks) expect(c.length).toBeLessThanOrEqual(2000); + expect(chunks.join('')).toBe(longLine); + }); + + it('keeps a closing fence with its block when there is room', () => { + const text = '```ts\nshort code\n```'; + const chunks = chunkForDiscord(text, 2000); + expect(chunks).toEqual([text]); + }); + + it('preserves total content across chunk seams (modulo reopen overhead)', () => { + const code = Array.from( + { length: 80 }, + (_, i) => `console.log(${i});`, + ).join('\n'); + const text = '```js\n' + code + '\n```'; + const chunks = chunkForDiscord(text, 300); + // After removing the reopen-close fence pairs that the chunker inserts + // between chunks, the result should match the original text. + const stripped = chunks.join('\n').replace(/```\n```js\n/g, ''); + // The first chunk still has the original opener and the last still has + // the original closer. + expect(stripped.startsWith('```js\n')).toBe(true); + expect(stripped.endsWith('```')).toBe(true); + expect(stripped.includes('console.log(0);')).toBe(true); + expect(stripped.includes('console.log(79);')).toBe(true); + }); +}); diff --git a/src/channels/discord.ts b/src/channels/discord.ts index 05b8fbf..8607998 100644 --- a/src/channels/discord.ts +++ b/src/channels/discord.ts @@ -39,6 +39,113 @@ const DISCORD_OWNER_TOKEN_KEY = 'DISCORD_OWNER_BOT_TOKEN'; const DISCORD_REVIEWER_TOKEN_KEY = 'DISCORD_REVIEWER_BOT_TOKEN'; const DISCORD_ARBITER_TOKEN_KEY = 'DISCORD_ARBITER_BOT_TOKEN'; +// Matches a line that opens or closes a markdown fenced code block. +// Captures the fence marker (``` or ```` etc.) and the optional language tag. +const FENCE_LINE_RE = /^(`{3,})([^\s`]*)\s*$/; + +/** + * Split `text` into chunks โ‰ค `maxLength` chars while keeping every chunk a + * valid standalone Markdown snippet for Discord. If a chunk has to end inside + * a ``` fenced code block, we append a closing fence to it and reopen the + * same fence (with the same language tag) at the start of the next chunk. + * + * Behaviour: + * - Prefers splitting at newline boundaries. + * - If a single line is itself longer than `maxLength`, falls back to a + * surrogate-pair-safe byte split for that line only. + * - Returns `[text]` unchanged when `text.length <= maxLength`. + */ +export function chunkForDiscord(text: string, maxLength: number): string[] { + if (text.length <= maxLength) return text.length > 0 ? [text] : []; + + const lines = text.split('\n'); + const chunks: string[] = []; + let current = ''; + let openLang = ''; // language tag of currently open fence + let openMarker: string | null = null; // ``` or ```` etc; null when not in a fence + + const flush = () => { + if (current === '') return; + if (openMarker !== null) { + if (!current.endsWith('\n')) current += '\n'; + current += openMarker; + chunks.push(current); + current = openMarker + openLang + '\n'; + } else { + chunks.push(current); + current = ''; + } + }; + + const hardSplitInto = (segment: string) => { + // Split `segment` across chunks at code-unit boundaries while avoiding + // splitting surrogate pairs. Used when a single source line exceeds the + // budget on its own. + let s = segment; + while (s.length > 0) { + const reserve = openMarker !== null ? openMarker.length + 1 : 0; + const room = maxLength - current.length - reserve; + if (room <= 0) { + flush(); + continue; + } + let take = Math.min(s.length, room); + if ( + take < s.length && + take > 0 && + s.charCodeAt(take - 1) >= 0xd800 && + s.charCodeAt(take - 1) <= 0xdbff + ) { + take--; + } + if (take <= 0) { + // Couldn't fit anything safely; flush and retry on an empty chunk. + flush(); + continue; + } + current += s.slice(0, take); + s = s.slice(take); + if (s.length > 0) flush(); + } + }; + + for (let li = 0; li < lines.length; li++) { + const isLast = li === lines.length - 1; + const line = lines[li]; + const lineWithNL = isLast ? line : line + '\n'; + const reserve = openMarker !== null ? openMarker.length + 1 : 0; + + if ( + current.length > 0 && + current.length + lineWithNL.length + reserve > maxLength + ) { + flush(); + } + + if (lineWithNL.length + reserve > maxLength) { + hardSplitInto(lineWithNL); + } else { + current += lineWithNL; + } + + // Update fence state AFTER appending the line so the very-next iteration + // accounts for it correctly. + const m = line.match(FENCE_LINE_RE); + if (m) { + if (openMarker === null) { + openMarker = m[1]; + openLang = m[2] || ''; + } else if (m[1] === openMarker) { + openMarker = null; + openLang = ''; + } + } + } + + if (current.length > 0) chunks.push(current); + return chunks; +} + /** * Wait for a pending transcription from the other service (poll cache file). * Returns the cached text, or null if timeout. @@ -467,10 +574,14 @@ export class DiscordChannel implements Channel { ); } } else { - // Send text in chunks, attach first batch to the first chunk + // Send text in chunks. The chunker preserves fenced code blocks so a + // long ``` block that straddles the 2000-char boundary doesn't get + // split mid-fence (which Discord would render as literal backticks + // and break syntax for following chunks). It is also surrogate-pair + // safe (emoji etc.). + const messageChunks = chunkForDiscord(cleaned, MAX_LENGTH); let fileBatchIndex = 0; - for (let i = 0; i < cleaned.length; i += MAX_LENGTH) { - const chunk = cleaned.slice(i, i + MAX_LENGTH); + for (const chunk of messageChunks) { const batch = fileBatches[fileBatchIndex]; recordSentMessage( await textChannel.send({ From 9483d4836ff0711135e19322208d88fbcb66b38f Mon Sep 17 00:00:00 2001 From: claude-bot Date: Mon, 1 Jun 2026 17:28:17 +0900 Subject: [PATCH 03/11] chore(scripts): add Discord bot permission diagnostic One-off script that logs in with each configured Discord bot token and prints the bot member's effective permission bits per guild, so capability claims can be verified empirically instead of inferred from intents. Co-Authored-By: Claude Opus 4 --- scripts/diagnose-discord-perms.ts | 98 +++++++++++++++++++++++++++++++ src/channels/discord.test.ts | 4 +- 2 files changed, 101 insertions(+), 1 deletion(-) create mode 100644 scripts/diagnose-discord-perms.ts diff --git a/scripts/diagnose-discord-perms.ts b/scripts/diagnose-discord-perms.ts new file mode 100644 index 0000000..0a867c2 --- /dev/null +++ b/scripts/diagnose-discord-perms.ts @@ -0,0 +1,98 @@ +#!/usr/bin/env bun +/** + * One-off diagnostic. Logs in with each configured Discord bot token, walks + * every guild it sees, and prints the bot member's effective permission bits. + * + * Output is meant to verify capability claims like "we have Read Message + * History" / "we have Manage Channels" empirically rather than guessing from + * intents. + */ +import { Client, GatewayIntentBits, PermissionsBitField } from 'discord.js'; +import fs from 'fs'; +import path from 'path'; + +function loadEnv(): Record { + const envPath = path.join(import.meta.dir, '..', '.env'); + if (!fs.existsSync(envPath)) return {}; + const out: Record = {}; + for (const line of fs.readFileSync(envPath, 'utf-8').split('\n')) { + const m = line.match(/^([A-Z_][A-Z0-9_]*)=(.*)$/); + if (m) out[m[1]] = m[2].replace(/^["']|["']$/g, ''); + } + return out; +} + +const env = loadEnv(); +const TOKENS: Array<[string, string]> = [ + ['owner', env.DISCORD_OWNER_BOT_TOKEN ?? ''], + ['reviewer', env.DISCORD_REVIEWER_BOT_TOKEN ?? ''], + ['arbiter', env.DISCORD_ARBITER_BOT_TOKEN ?? ''], +].filter(([, t]) => t.length > 0) as Array<[string, string]>; + +// Subset of permissions we actually care about for the capability matrix. +const RELEVANT = [ + 'ViewChannel', + 'SendMessages', + 'EmbedLinks', + 'AttachFiles', + 'ReadMessageHistory', + 'AddReactions', + 'ManageMessages', + 'ManageChannels', + 'ManageThreads', + 'CreatePublicThreads', + 'CreatePrivateThreads', + 'SendMessagesInThreads', + 'MentionEveryone', + 'UseExternalEmojis', +] as const; + +async function diagnose(role: string, token: string) { + const client = new Client({ + intents: [ + GatewayIntentBits.Guilds, + GatewayIntentBits.GuildMessages, + GatewayIntentBits.MessageContent, + GatewayIntentBits.DirectMessages, + ], + }); + + await new Promise((resolve, reject) => { + client.once('ready', () => resolve()); + client.once('error', reject); + client.login(token).catch(reject); + }); + + console.log( + `\n=== role=${role} bot=${client.user?.tag} (id=${client.user?.id}) ===`, + ); + const guilds = await client.guilds.fetch(); + for (const [gid, partial] of guilds) { + const guild = await partial.fetch(); + const me = await guild.members.fetchMe(); + const perms = me.permissions; + const has: Record = {}; + for (const name of RELEVANT) { + has[name] = perms.has( + PermissionsBitField.Flags[ + name as keyof typeof PermissionsBitField.Flags + ], + ); + } + console.log(` guild=${guild.name} (id=${gid})`); + console.log( + ` admin=${perms.has(PermissionsBitField.Flags.Administrator)}`, + ); + for (const name of RELEVANT) console.log(` ${name}=${has[name]}`); + } + client.destroy(); +} + +for (const [role, token] of TOKENS) { + try { + await diagnose(role, token); + } catch (err) { + console.log(`\n=== role=${role} FAILED ===`); + console.log(err); + } +} diff --git a/src/channels/discord.test.ts b/src/channels/discord.test.ts index 438a9aa..2463ca7 100644 --- a/src/channels/discord.test.ts +++ b/src/channels/discord.test.ts @@ -1226,7 +1226,9 @@ describe('chunkForDiscord', () => { const chunks = chunkForDiscord(text, 9); for (const c of chunks) { // No chunk should start or end mid surrogate pair. - expect(c.charCodeAt(0) >= 0xdc00 && c.charCodeAt(0) <= 0xdfff).toBe(false); + expect(c.charCodeAt(0) >= 0xdc00 && c.charCodeAt(0) <= 0xdfff).toBe( + false, + ); const last = c.charCodeAt(c.length - 1); expect(last >= 0xd800 && last <= 0xdbff).toBe(false); } From f924db78edec7ecb39fe76f5776ce387c98ab12e Mon Sep 17 00:00:00 2001 From: claude-bot Date: Mon, 1 Jun 2026 17:29:19 +0900 Subject: [PATCH 04/11] fix(dashboard): preserve registered room aliases Stop overwriting the user-registered group/room name with the live Discord channel name during channel-meta refresh; keep the alias and log the divergence at debug instead. Co-Authored-By: Claude Opus 4 --- src/unified-dashboard.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/unified-dashboard.ts b/src/unified-dashboard.ts index 3f581f2..1e27a2f 100644 --- a/src/unified-dashboard.ts +++ b/src/unified-dashboard.ts @@ -51,7 +51,7 @@ import { mergeClaudeDashboardAccounts, type UsageRow, } from './dashboard-usage-rows.js'; -import { getAllChats, getAllTasks, updateRegisteredGroupName } from './db.js'; +import { getAllChats, getAllTasks } from './db.js'; import type { GroupQueue } from './group-queue.js'; import { logger } from './logger.js'; import { isWatchCiTask } from './task-watch-status.js'; @@ -208,12 +208,10 @@ async function refreshChannelMeta( if (!meta.name) continue; const group = opts.roomBindings()[jid]; if (!group || group.name === meta.name) continue; - logger.info( + logger.debug( { jid, oldName: group.name, newName: meta.name }, - 'Syncing group name to Discord channel name', + 'Keeping registered group name distinct from Discord channel name', ); - updateRegisteredGroupName(jid, meta.name); - opts.onGroupNameSynced?.(jid, meta.name); } } catch (err) { logger.debug({ err }, 'Failed to refresh channel metadata'); From 7e16fce17ee9444799c41872dcc20b86e2b252f2 Mon Sep 17 00:00:00 2001 From: claude-bot Date: Mon, 1 Jun 2026 17:29:56 +0900 Subject: [PATCH 05/11] docs(prompts): require finalize message to be a self-contained answer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The user only sees the final owner message, not the ownerโ†”reviewer loop. Add a "Finalize message format" section mandating a direct answer, a consolidated recap (no transcript, no narrating disagreement), an explicit ์‚ฌ์šฉ์ž ์•ก์…˜ ์•„์ดํ…œ section (with "์—†์Œ" when nothing is needed), and no reviewer-loop meta phrases. Co-Authored-By: Claude Opus 4 --- prompts/owner-common-paired-room.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/prompts/owner-common-paired-room.md b/prompts/owner-common-paired-room.md index 8055b1b..4c4a607 100644 --- a/prompts/owner-common-paired-room.md +++ b/prompts/owner-common-paired-room.md @@ -54,6 +54,25 @@ Use short Markdown notes when they materially help handoff or continuity across - In that same finalize step, **DONE_WITH_CONCERNS** does not close the turn โ€” it intentionally reopens review - Use **DONE_WITH_CONCERNS** on finalize only when you are explicitly asking the reviewer loop to resume +### Finalize message format (self-contained answer) + +The user does **not** see the ownerโ†”reviewer back-and-forth. They only see the final owner message. So when you close the turn (TASK_DONE / STEP_DONE / BLOCKED / NEEDS_CONTEXT โ€” anything that becomes the user-visible reply), that single message must be a complete answer to the user's original question by itself. Reviewer corrections, intermediate dead-ends, and re-runs are invisible to the user โ€” fold them into the final answer instead of referring to them. + +Every finalize message must contain, in this order: + +1. **Status line** (e.g. `TASK_DONE`) โ€” first line as already required. +2. **Direct answer / conclusion** โ€” what the user actually asked, answered in 1โ€“3 lines. If they asked a yes/no question, lead with yes/no. +3. **Summary of what was done** โ€” a short consolidated recap of the whole ownerโ†”reviewer thread for this turn (not a transcript). Cover: what was investigated, what was changed (files / commits / config), what was verified (tests, diagnostics, live checks). Merge any reviewer corrections into the story as if you had been right the first time โ€” do not narrate the disagreement. +4. **์‚ฌ์šฉ์ž ์•ก์…˜ ์•„์ดํ…œ (Action required from user)** โ€” an explicit section listing anything the user must do themselves: restart a service the bot can't restart, toggle a Discord Developer Portal setting, approve a destructive action, decide between options, install a missing credential, merge a PR, etc. If there is nothing for the user to do, write exactly `์‚ฌ์šฉ์ž ์•ก์…˜ ์•„์ดํ…œ: ์—†์Œ` (or `Action required from user: none`). Never omit this section โ€” silence is ambiguous. +5. **(Optional) Follow-up suggestions** โ€” only if genuinely useful, kept short. + +Do **not** include: +- "๋ฆฌ๋ทฐ์–ด๊ฐ€ ์ง€์ ํ•ด์„œ ์ •์ •ํ–ˆ์Šต๋‹ˆ๋‹ค", "PROCEED ํ™•์ •", "๋ฆฌ๋ทฐ์–ด ํ”ผ๋“œ๋ฐฑ ๋ฐ˜์˜" ๊ฐ™์€ ๋ฉ”ํƒ€ ๋ฌธ๊ตฌ. The user doesn't know there was a reviewer. +- A blow-by-blow of each round. +- Apologies for earlier owner mistakes that the user never saw. + +The default reply language is the user's language (Korean for this deployment) unless the user wrote in another language. + ## Rules - Judge completion only by verification output. "It should work now" means run it. "I'm confident" means nothing โ€” confidence is not evidence. "I tested earlier" means test again if code changed since. "It's a trivial change" means verify anyway From ccc747ae1cc7560371652b841d2f2b5897ff11a9 Mon Sep 17 00:00:00 2001 From: claude-bot Date: Mon, 1 Jun 2026 17:38:35 +0900 Subject: [PATCH 06/11] fix(paired): cap consecutive reviewer silent-failure retries --- src/db/base-schema.ts | 1 + src/db/bootstrap.test.ts | 1 + .../migrations/019_reviewer-failure-count.ts | 23 ++++++ src/db/migrations/index.ts | 2 + src/db/paired-state.ts | 13 ++- src/paired-arbiter-request.ts | 1 + src/paired-execution-context-owner.ts | 1 + src/paired-execution-context-reviewer.ts | 79 ++++++++++++++++--- src/paired-execution-context.ts | 3 + src/paired-task-status.ts | 2 + src/types.ts | 1 + 11 files changed, 116 insertions(+), 11 deletions(-) create mode 100644 src/db/migrations/019_reviewer-failure-count.ts diff --git a/src/db/base-schema.ts b/src/db/base-schema.ts index 5db1b72..b3d2447 100644 --- a/src/db/base-schema.ts +++ b/src/db/base-schema.ts @@ -142,6 +142,7 @@ export function applyBaseSchema(database: Database): void { review_requested_at TEXT, round_trip_count INTEGER NOT NULL DEFAULT 0, owner_failure_count INTEGER NOT NULL DEFAULT 0, + reviewer_failure_count INTEGER NOT NULL DEFAULT 0, owner_step_done_streak INTEGER NOT NULL DEFAULT 0, finalize_step_done_count INTEGER NOT NULL DEFAULT 0, task_done_then_user_reopen_count INTEGER NOT NULL DEFAULT 0, diff --git a/src/db/bootstrap.test.ts b/src/db/bootstrap.test.ts index 47d8451..ad288de 100644 --- a/src/db/bootstrap.test.ts +++ b/src/db/bootstrap.test.ts @@ -43,6 +43,7 @@ function getExpectedSchemaMigrations(): Array<{ { version: 16, name: 'room_skill_overrides' }, { version: 17, name: 'scheduled_task_room_role' }, { version: 18, name: 'paired_turn_output_attachments' }, + { version: 19, name: 'reviewer_failure_count' }, ]; } diff --git a/src/db/migrations/019_reviewer-failure-count.ts b/src/db/migrations/019_reviewer-failure-count.ts new file mode 100644 index 0000000..5d9f20e --- /dev/null +++ b/src/db/migrations/019_reviewer-failure-count.ts @@ -0,0 +1,23 @@ +import type { Database } from 'bun:sqlite'; + +import { tableHasColumn } from './helpers.js'; +import type { SchemaMigrationDefinition } from './types.js'; + +export const REVIEWER_FAILURE_COUNT_MIGRATION: SchemaMigrationDefinition = { + version: 19, + name: 'reviewer_failure_count', + apply(database: Database) { + if (!tableHasColumn(database, 'paired_tasks', 'reviewer_failure_count')) { + database.exec(` + ALTER TABLE paired_tasks + ADD COLUMN reviewer_failure_count INTEGER NOT NULL DEFAULT 0 + `); + } + + database.exec(` + UPDATE paired_tasks + SET reviewer_failure_count = 0 + WHERE reviewer_failure_count IS NULL + `); + }, +}; diff --git a/src/db/migrations/index.ts b/src/db/migrations/index.ts index 38f8702..ebf67e5 100644 --- a/src/db/migrations/index.ts +++ b/src/db/migrations/index.ts @@ -18,6 +18,7 @@ import { TURN_PROGRESS_TEXT_MIGRATION } from './015_turn-progress-text.js'; import { ROOM_SKILL_OVERRIDES_MIGRATION } from './016_room-skill-overrides.js'; import { SCHEDULED_TASK_ROOM_ROLE_MIGRATION } from './017_scheduled-task-room-role.js'; import { PAIRED_TURN_OUTPUT_ATTACHMENTS_MIGRATION } from './018_paired-turn-output-attachments.js'; +import { REVIEWER_FAILURE_COUNT_MIGRATION } from './019_reviewer-failure-count.js'; import type { SchemaMigrationArgs, SchemaMigrationDefinition, @@ -44,6 +45,7 @@ const ORDERED_SCHEMA_MIGRATIONS: readonly SchemaMigrationDefinition[] = [ ROOM_SKILL_OVERRIDES_MIGRATION, SCHEDULED_TASK_ROOM_ROLE_MIGRATION, PAIRED_TURN_OUTPUT_ATTACHMENTS_MIGRATION, + REVIEWER_FAILURE_COUNT_MIGRATION, ]; function ensureSchemaMigrationsTable(database: Database): void { diff --git a/src/db/paired-state.ts b/src/db/paired-state.ts index 6c5ba78..b5dea17 100644 --- a/src/db/paired-state.ts +++ b/src/db/paired-state.ts @@ -50,6 +50,7 @@ export type PairedTaskUpdates = Partial< | 'review_requested_at' | 'round_trip_count' | 'owner_failure_count' + | 'reviewer_failure_count' | 'owner_step_done_streak' | 'finalize_step_done_count' | 'task_done_then_user_reopen_count' @@ -176,6 +177,7 @@ export function createPairedTaskInDatabase( review_requested_at, round_trip_count, owner_failure_count, + reviewer_failure_count, owner_step_done_streak, finalize_step_done_count, task_done_then_user_reopen_count, @@ -187,7 +189,7 @@ export function createPairedTaskInDatabase( created_at, updated_at ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, ) .run( @@ -205,6 +207,7 @@ export function createPairedTaskInDatabase( task.review_requested_at, task.round_trip_count, task.owner_failure_count ?? 0, + task.reviewer_failure_count ?? 0, task.owner_step_done_streak ?? 0, task.finalize_step_done_count ?? 0, task.task_done_then_user_reopen_count ?? 0, @@ -339,6 +342,10 @@ export function updatePairedTaskInDatabase( fields.push('owner_failure_count = ?'); values.push(updates.owner_failure_count); } + if (updates.reviewer_failure_count !== undefined) { + fields.push('reviewer_failure_count = ?'); + values.push(updates.reviewer_failure_count); + } if (updates.owner_step_done_streak !== undefined) { fields.push('owner_step_done_streak = ?'); values.push(updates.owner_step_done_streak); @@ -418,6 +425,10 @@ export function updatePairedTaskIfUnchangedInDatabase( fields.push('owner_failure_count = ?'); values.push(updates.owner_failure_count); } + if (updates.reviewer_failure_count !== undefined) { + fields.push('reviewer_failure_count = ?'); + values.push(updates.reviewer_failure_count); + } if (updates.owner_step_done_streak !== undefined) { fields.push('owner_step_done_streak = ?'); values.push(updates.owner_step_done_streak); diff --git a/src/paired-arbiter-request.ts b/src/paired-arbiter-request.ts index b653a75..8abdb4d 100644 --- a/src/paired-arbiter-request.ts +++ b/src/paired-arbiter-request.ts @@ -18,6 +18,7 @@ export function requestArbiterOrEscalate(args: { review_requested_at?: string | null; round_trip_count?: number; owner_failure_count?: number; + reviewer_failure_count?: number; owner_step_done_streak?: number; finalize_step_done_count?: number; task_done_then_user_reopen_count?: number; diff --git a/src/paired-execution-context-owner.ts b/src/paired-execution-context-owner.ts index 2d28c96..e9f0bd8 100644 --- a/src/paired-execution-context-owner.ts +++ b/src/paired-execution-context-owner.ts @@ -293,6 +293,7 @@ function maybeAutoTriggerReviewerAfterOwnerCompletion(args: { patch: { round_trip_count: task.round_trip_count + 1, owner_failure_count: 0, + reviewer_failure_count: 0, owner_step_done_streak: 0, empty_step_done_streak: 0, ...args.patch, diff --git a/src/paired-execution-context-reviewer.ts b/src/paired-execution-context-reviewer.ts index 8dc0d58..9927e4f 100644 --- a/src/paired-execution-context-reviewer.ts +++ b/src/paired-execution-context-reviewer.ts @@ -2,7 +2,10 @@ import { ARBITER_DEADLOCK_THRESHOLD } from './config.js'; import { getPairedWorkspace } from './db.js'; import { logger } from './logger.js'; import { requestArbiterOrEscalate } from './paired-arbiter-request.js'; -import { transitionPairedTaskStatus } from './paired-task-status.js'; +import { + applyPairedTaskPatch, + transitionPairedTaskStatus, +} from './paired-task-status.js'; import { resolveReviewerCompletionSignal, resolveReviewerFailureSignal, @@ -11,6 +14,17 @@ import { resolveCanonicalSourceRef } from './paired-source-ref.js'; import { parseVisibleVerdict } from './paired-verdict.js'; import type { PairedTask } from './types.js'; +/** + * Maximum consecutive reviewer executions that may produce no verdict + * before we stop the ping-pong loop. Mirrors the owner-side failure cap and + * closes the "reviewer silently fails forever" hole: codex sometimes emits a + * final result with no visible text, the runtime marks it failed, the + * next-turn scheduler re-queues another reviewer-turn, and the original + * round_trip_count never increments โ€” so the existing round-trip / arbiter + * caps never fire. + */ +const REVIEWER_FAILURE_ESCALATION_THRESHOLD = 2; + export function handleFailedReviewerExecution(args: { task: PairedTask; taskId: string; @@ -75,6 +89,35 @@ export function handleFailedReviewerExecution(args: { } } + // Reviewer produced no usable verdict and no recognised infrastructure + // failure. Track consecutive flakes โ€” after the threshold, stop the + // ping-pong loop instead of preserving review_ready forever. + const nextFailureCount = (task.reviewer_failure_count ?? 0) + 1; + + if (nextFailureCount >= REVIEWER_FAILURE_ESCALATION_THRESHOLD) { + requestArbiterOrEscalate({ + taskId, + currentStatus: task.status, + expectedUpdatedAt: task.updated_at, + now, + arbiterLogMessage: + 'Reviewer failed repeatedly without a visible verdict โ€” requesting arbiter', + escalateLogMessage: + 'Reviewer failed repeatedly without a visible verdict โ€” escalating to user', + logContext: { + taskId, + role: 'reviewer', + previousStatus: task.status, + reviewerFailureCount: nextFailureCount, + summary: summary?.slice(0, 160), + }, + patch: { + reviewer_failure_count: nextFailureCount, + }, + }); + return; + } + const fallbackStatus = task.status === 'in_review' || task.status === 'review_ready' ? 'review_ready' @@ -86,17 +129,30 @@ export function handleFailedReviewerExecution(args: { nextStatus: fallbackStatus, expectedUpdatedAt: task.updated_at, updatedAt: now, - }); - logger.warn( - { - taskId, - role: 'reviewer', - previousStatus: task.status, - nextStatus: fallbackStatus, + patch: { + reviewer_failure_count: nextFailureCount, }, - 'Preserved reviewer task in review-ready state after failed execution', - ); + }); + } else { + applyPairedTaskPatch({ + taskId, + expectedUpdatedAt: task.updated_at, + updatedAt: now, + patch: { + reviewer_failure_count: nextFailureCount, + }, + }); } + logger.warn( + { + taskId, + role: 'reviewer', + previousStatus: task.status, + reviewerFailureCount: nextFailureCount, + summary: summary?.slice(0, 160), + }, + 'Preserved reviewer task in review-ready state after failed execution', + ); } export function handleReviewerCompletion(args: { @@ -128,6 +184,7 @@ export function handleReviewerCompletion(args: { patch: { source_ref: approvedSourceRef, owner_failure_count: 0, + reviewer_failure_count: 0, owner_step_done_streak: 0, finalize_step_done_count: 0, empty_step_done_streak: 0, @@ -164,6 +221,7 @@ export function handleReviewerCompletion(args: { arbiterLogMessage, escalateLogMessage, logContext: { taskId, verdict, summary: summary?.slice(0, 100) }, + patch: { reviewer_failure_count: 0 }, }); return; } @@ -175,6 +233,7 @@ export function handleReviewerCompletion(args: { nextStatus: 'active', expectedUpdatedAt: task.updated_at, updatedAt: now, + patch: { reviewer_failure_count: 0 }, }); logger.info( { taskId, verdict }, diff --git a/src/paired-execution-context.ts b/src/paired-execution-context.ts index 216f56e..3dca9f7 100644 --- a/src/paired-execution-context.ts +++ b/src/paired-execution-context.ts @@ -141,6 +141,7 @@ function createActiveTaskForRoom(args: { review_requested_at: null, round_trip_count: 0, owner_failure_count: 0, + reviewer_failure_count: 0, owner_step_done_streak: 0, finalize_step_done_count: 0, task_done_then_user_reopen_count: 0, @@ -474,6 +475,7 @@ export function preparePairedExecutionContext(args: { ? { round_trip_count: 0, owner_failure_count: 0, + reviewer_failure_count: 0, owner_step_done_streak: 0, empty_step_done_streak: 0, } @@ -490,6 +492,7 @@ export function preparePairedExecutionContext(args: { ? { round_trip_count: 0, owner_failure_count: 0, + reviewer_failure_count: 0, owner_step_done_streak: 0, empty_step_done_streak: 0, } diff --git a/src/paired-task-status.ts b/src/paired-task-status.ts index 1325552..fd521d1 100644 --- a/src/paired-task-status.ts +++ b/src/paired-task-status.ts @@ -62,6 +62,7 @@ export function transitionPairedTaskStatus(args: { review_requested_at?: string | null; round_trip_count?: number; owner_failure_count?: number; + reviewer_failure_count?: number; owner_step_done_streak?: number; finalize_step_done_count?: number; task_done_then_user_reopen_count?: number; @@ -110,6 +111,7 @@ export function applyPairedTaskPatch(args: { review_requested_at?: string | null; round_trip_count?: number; owner_failure_count?: number; + reviewer_failure_count?: number; owner_step_done_streak?: number; finalize_step_done_count?: number; task_done_then_user_reopen_count?: number; diff --git a/src/types.ts b/src/types.ts index 7c78c22..fdc1089 100644 --- a/src/types.ts +++ b/src/types.ts @@ -128,6 +128,7 @@ export interface PairedTask { review_requested_at: string | null; round_trip_count: number; owner_failure_count?: number | null; + reviewer_failure_count?: number | null; owner_step_done_streak?: number | null; finalize_step_done_count?: number | null; task_done_then_user_reopen_count?: number | null; From 5af1c5b1d1d8a7f59b3c237a724271186314f074 Mon Sep 17 00:00:00 2001 From: claude-bot Date: Mon, 1 Jun 2026 17:49:54 +0900 Subject: [PATCH 07/11] fix(router): escape Discord markdown delimiters instead of stripping --- src/index.ts | 8 +- src/message-turn-controller.ts | 7 +- src/router.test.ts | 133 +++++++++++++++++++++++++++++++++ src/router.ts | 91 +++++++++++++++++++++- src/session-commands.ts | 4 +- 5 files changed, 234 insertions(+), 9 deletions(-) diff --git a/src/index.ts b/src/index.ts index 47282a6..f340183 100644 --- a/src/index.ts +++ b/src/index.ts @@ -34,7 +34,7 @@ import { deliverCanonicalOutboundMessage, deliverIpcOutboundMessage, } from './ipc-outbound-delivery.js'; -import { findChannel, formatOutbound } from './router.js'; +import { findChannel, sanitizeForOutbound } from './router.js'; import { buildRestartAnnouncement, buildInterruptedRestartAnnouncement, @@ -99,7 +99,7 @@ export async function sendFormattedTrackedChannelMessage( logger.warn({ jid }, 'No channel owns JID, cannot send tracked message'); return null; } - const text = formatOutbound(rawText); + const text = sanitizeForOutbound(rawText); if (!text || !channel.sendAndTrack) return null; return channel.sendAndTrack(jid, text); } @@ -115,7 +115,7 @@ export async function editFormattedTrackedChannelMessage( logger.warn({ jid }, 'No channel owns JID, cannot edit tracked message'); return; } - const text = formatOutbound(rawText); + const text = sanitizeForOutbound(rawText); if (!text || !channel.editMessage) return; await channel.editMessage(jid, messageId, text); } @@ -146,7 +146,7 @@ async function deliverFormattedCanonicalMessage( rawText: string, deliveryRole?: 'owner' | 'reviewer' | 'arbiter', ): Promise { - const text = formatOutbound(rawText); + const text = sanitizeForOutbound(rawText); if (!text) return; await deliverCanonicalOutboundMessage( { jid, text, deliveryRole }, diff --git a/src/message-turn-controller.ts b/src/message-turn-controller.ts index c8d51b0..b99bc65 100644 --- a/src/message-turn-controller.ts +++ b/src/message-turn-controller.ts @@ -4,7 +4,7 @@ import { getAgentOutputText, } from './agent-output.js'; import { createScopedLogger, logger } from './logger.js'; -import { formatOutbound } from './router.js'; +import { sanitizeForOutbound } from './router.js'; import { shouldResetSessionOnAgentFailure } from './session-recovery.js'; import { TASK_STATUS_MESSAGE_PREFIX } from './task-watch-status.js'; import { formatElapsedKorean } from './utils.js'; @@ -169,7 +169,10 @@ export class MessageTurnController { } const raw = getAgentOutputText(result); - const text = raw ? formatOutbound(raw) : null; + // Use sanitize (no markdown escape) โ€” the Discord channel boundary + // applies the final escape pass, and double-escaping would produce + // visible backslash garbage. + const text = raw ? sanitizeForOutbound(raw) : null; const attachments = getAgentOutputAttachments(result); if (raw) { diff --git a/src/router.test.ts b/src/router.test.ts index f97b6ca..4127490 100644 --- a/src/router.test.ts +++ b/src/router.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it } from 'vitest'; import { findChannelForDeliveryRole, + neutralizeStrayBackticks, + neutralizeStrayMarkdown, resolveChannelForDeliveryRole, } from './router.js'; import { type Channel } from './types.js'; @@ -92,3 +94,134 @@ describe('findChannelForDeliveryRole', () => { ); }); }); + +describe('neutralizeStrayMarkdown', () => { + it('escapes inline backticks so they render literally', () => { + expect(neutralizeStrayMarkdown('use `foo` and `bar` here')).toBe( + 'use \\`foo\\` and \\`bar\\` here', + ); + }); + + it('preserves well-formed fenced code blocks and escapes outside prose', () => { + const input = 'before\n```ts\nconst x = `tpl`;\n```\nafter `inline`'; + expect(neutralizeStrayMarkdown(input)).toBe( + 'before\n```ts\nconst x = `tpl`;\n```\nafter \\`inline\\`', + ); + }); + + it('escapes emphasis in an unterminated fence (treated as prose)', () => { + expect(neutralizeStrayMarkdown('oops ```code without close')).toBe( + 'oops \\`\\`\\`code without close', + ); + }); + + it('passes through plain text unchanged', () => { + const s = 'plain text with no markup'; + expect(neutralizeStrayMarkdown(s)).toBe(s); + }); + + it('escapes italic asterisks', () => { + expect(neutralizeStrayMarkdown('์ด๊ฑด *๊ธฐ์šธ์ž„* ์ฒ˜๋ฆฌ')).toBe( + '์ด๊ฑด \\*๊ธฐ์šธ์ž„\\* ์ฒ˜๋ฆฌ', + ); + }); + + it('escapes bold asterisks', () => { + expect(neutralizeStrayMarkdown('์ด๊ฑด **๊ฐ•์กฐ** ์ฒ˜๋ฆฌ')).toBe( + '์ด๊ฑด \\*\\*๊ฐ•์กฐ\\*\\* ์ฒ˜๋ฆฌ', + ); + }); + + it('escapes bold-italic triple asterisks', () => { + expect(neutralizeStrayMarkdown('***hybrid***')).toBe( + '\\*\\*\\*hybrid\\*\\*\\*', + ); + }); + + it('escapes italic underscores at word boundaries', () => { + expect(neutralizeStrayMarkdown('์ด๊ฑด _italic_ ์ฒ˜๋ฆฌ')).toBe( + '์ด๊ฑด \\_italic\\_ ์ฒ˜๋ฆฌ', + ); + }); + + it('escapes every underscore including snake_case identifiers', () => { + expect(neutralizeStrayMarkdown('use foo_bar_baz here')).toBe( + 'use foo\\_bar\\_baz here', + ); + }); + + it('escapes underscores in absolute paths', () => { + expect( + neutralizeStrayMarkdown( + '๊ฒฝ๋กœ: /home/claude/EJClaw/groups/mc_the_scene_plugin/src/main_file.ts', + ), + ).toBe( + '๊ฒฝ๋กœ: /home/claude/EJClaw/groups/mc\\_the\\_scene\\_plugin/src/main\\_file.ts', + ); + }); + + it('escapes underscores and backslashes in Windows paths', () => { + expect( + neutralizeStrayMarkdown( + '๊ฒฝ๋กœ: C:\\Custom_vscode\\minecraft_launcher\\src\\main_file.ts', + ), + ).toBe( + '๊ฒฝ๋กœ: C:\\\\Custom\\_vscode\\\\minecraft\\_launcher\\\\src\\\\main\\_file.ts', + ); + }); + + it('escapes double-underscore underline markers', () => { + expect(neutralizeStrayMarkdown('__bold__ text')).toBe( + '\\_\\_bold\\_\\_ text', + ); + }); + + it('escapes heading hashes at line start', () => { + expect(neutralizeStrayMarkdown('## ๋นŒ๋“œ ํƒ€์ž„ ๋ฉ”ํƒ€\n๋ณธ๋ฌธ')).toBe( + '\\## ๋นŒ๋“œ ํƒ€์ž„ ๋ฉ”ํƒ€\n๋ณธ๋ฌธ', + ); + }); + + it('escapes block-quote markers at line start', () => { + expect(neutralizeStrayMarkdown('> quoted line\n> another')).toBe( + '\\> quoted line\n\\> another', + ); + }); + + it('leaves mid-line hash and gt unchanged (not heading/quote markers)', () => { + expect(neutralizeStrayMarkdown('a #tag here and a > b')).toBe( + 'a #tag here and a > b', + ); + }); + + it('escapes strikethrough tildes', () => { + expect(neutralizeStrayMarkdown('์ด๊ฑด ~~์ทจ์†Œ์„ ~~ ์ž…๋‹ˆ๋‹ค')).toBe( + '์ด๊ฑด \\~\\~์ทจ์†Œ์„ \\~\\~ ์ž…๋‹ˆ๋‹ค', + ); + }); + + it('escapes spoiler pipes', () => { + expect(neutralizeStrayMarkdown('||๋น„๋ฐ€||')).toBe('\\|\\|๋น„๋ฐ€\\|\\|'); + }); + + it('preserves Discord mention syntax (no markdown delimiters inside)', () => { + expect(neutralizeStrayMarkdown('<@123> hi <#456>')).toBe( + '<@123> hi <#456>', + ); + }); + + it('preserves code fence content while escaping prose emphasis', () => { + const input = '**bold** outside\n```py\nx = *2\n```\nand *more*'; + expect(neutralizeStrayMarkdown(input)).toBe( + '\\*\\*bold\\*\\* outside\n```py\nx = *2\n```\nand \\*more\\*', + ); + }); + + it('escapes literal backslashes first so new escapes are not doubled', () => { + expect(neutralizeStrayMarkdown('a\\b *c*')).toBe('a\\\\b \\*c\\*'); + }); + + it('back-compat alias still exported', () => { + expect(neutralizeStrayBackticks('*x*')).toBe('\\*x\\*'); + }); +}); diff --git a/src/router.ts b/src/router.ts index 5cbc161..fcf5d65 100644 --- a/src/router.ts +++ b/src/router.ts @@ -78,7 +78,85 @@ export function stripToolCallLeaks(text: string): string { return stripped.replace(/\n{3,}/g, '\n\n').trim(); } -export function formatOutbound(rawText: string): string { +/** + * Escape Discord markdown delimiters in a prose segment so the source + * characters render literally instead of triggering formatting. Used on + * non-code segments only โ€” callers must preserve fenced code blocks + * separately. + * + * Discord rendering reference: + * *italic*, _italic_, **bold**, ***bold-italic*** + * __underline__ ~~strike~~ ||spoiler|| + * `inline` # H1 ## H2 ### H3 (at line start) + * > quote, >>> multi-line quote (at line start) + * + * `<@id>`/`<#id>`/`<:emoji:id>` mentions and `[text](url)` links contain no + * markdown delimiters and pass through unchanged. + */ +function escapeMarkdownInProse(segment: string): string { + return ( + segment + // Escape backslashes first so we don't double-escape the backslashes + // we are about to introduce for the other markers. + .replace(/\\/g, '\\\\') + // Inline markdown delimiters โ€” escape positionally so Discord prints + // them as literal characters. + .replace(/`/g, '\\`') + .replace(/\*/g, '\\*') + .replace(/_/g, '\\_') + .replace(/~/g, '\\~') + .replace(/\|/g, '\\|') + // Heading hashes โ€” only meaningful at the start of a line (after + // optional indent) and followed by a space. Escape only the first + // hash; the rest are now harmless literal characters. + .replace(/^([ \t]*)(#)(?=#{0,2}[ \t])/gm, '$1\\$2') + // Block-quote markers โ€” same line-start constraint. + .replace(/^([ \t]*)(>)(?=>{0,2}([ \t]|$))/gm, '$1\\$2') + ); +} + +/** + * Escape stray Discord markdown delimiters in prose while preserving + * well-formed triple-backtick fenced code blocks (legit code snippets). + */ +export function neutralizeStrayMarkdown(text: string): string { + if (!text) return text; + const parts: string[] = []; + let i = 0; + while (i < text.length) { + const fenceStart = text.indexOf('```', i); + if (fenceStart === -1) { + parts.push(escapeMarkdownInProse(text.slice(i))); + break; + } + parts.push(escapeMarkdownInProse(text.slice(i, fenceStart))); + const fenceEnd = text.indexOf('```', fenceStart + 3); + if (fenceEnd === -1) { + // Unterminated fence โ€” not a real code block; treat as prose. + parts.push(escapeMarkdownInProse(text.slice(fenceStart))); + break; + } + // Preserve the entire fenced block including delimiters. + parts.push(text.slice(fenceStart, fenceEnd + 3)); + i = fenceEnd + 3; + } + return parts.join(''); +} + +/** @deprecated Kept for back-compat; use neutralizeStrayMarkdown. */ +export const neutralizeStrayBackticks = neutralizeStrayMarkdown; + +/** + * Sanitize raw agent output for internal use (storage, IPC, intermediate + * channel buffers). Strips internal tags + tool-call leaks and redacts + * secrets, but does NOT touch markdown delimiters. + * + * Use this when the text will pass through another `formatOutbound` call + * downstream (e.g., the Discord channel boundary). Applying the markdown + * escape twice would double-escape backslashes and produce visible garbage + * in Discord. + */ +export function sanitizeForOutbound(rawText: string): string { let text = stripInternalTags(rawText); if (!text) return ''; text = stripToolCallLeaks(text); @@ -86,6 +164,17 @@ export function formatOutbound(rawText: string): string { return redactSecrets(text); } +/** + * Full outbound formatting for the final Discord-send boundary: sanitize + * + escape Discord markdown delimiters. Call this exactly once per + * outbound message, at the channel boundary. + */ +export function formatOutbound(rawText: string): string { + const sanitized = sanitizeForOutbound(rawText); + if (!sanitized) return ''; + return neutralizeStrayMarkdown(sanitized); +} + export function findChannel( channels: Channel[], jid: string, diff --git a/src/session-commands.ts b/src/session-commands.ts index 1a2fd5f..3bbd0b9 100644 --- a/src/session-commands.ts +++ b/src/session-commands.ts @@ -1,7 +1,7 @@ import { getAgentOutputText } from './agent-output.js'; import type { NewMessage } from './types.js'; import { logger } from './logger.js'; -import { formatOutbound } from './router.js'; +import { sanitizeForOutbound } from './router.js'; import type { StructuredAgentOutput } from './types.js'; const SESSION_COMMAND_CONTROL_PATTERNS = [ @@ -86,7 +86,7 @@ function agentResultToText(result: AgentResult): string { result: result.result ?? null, output: result.output, }); - return raw ? formatOutbound(raw) : ''; + return raw ? sanitizeForOutbound(raw) : ''; } /** From e9dd09a03685addc93db5e80576c1d127717856c Mon Sep 17 00:00:00 2001 From: claude-bot Date: Mon, 1 Jun 2026 17:50:04 +0900 Subject: [PATCH 08/11] test(paired): cover reviewer silent-failure retry cap --- src/paired-execution-context.test.ts | 30 +++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/src/paired-execution-context.test.ts b/src/paired-execution-context.test.ts index 918b167..d6cac42 100644 --- a/src/paired-execution-context.test.ts +++ b/src/paired-execution-context.test.ts @@ -143,6 +143,7 @@ function buildPairedTask(overrides: Partial = {}): PairedTask { review_requested_at: null, round_trip_count: 0, owner_failure_count: 0, + reviewer_failure_count: 0, owner_step_done_streak: 0, finalize_step_done_count: 0, task_done_then_user_reopen_count: 0, @@ -1322,7 +1323,7 @@ describe('paired execution context', () => { ); }); - it('keeps reviewer tasks review_ready when reviewer execution fails without a terminal verdict', () => { + it('keeps reviewer tasks review_ready and increments reviewer_failure_count after the first silent failure', () => { vi.mocked(db.getPairedTaskById).mockReturnValue( buildPairedTask({ status: 'in_review', @@ -1340,11 +1341,38 @@ describe('paired execution context', () => { 'task-1', expect.objectContaining({ status: 'review_ready', + reviewer_failure_count: 1, updated_at: expect.any(String), }), ); }); + it('requests arbiter after repeated reviewer execution failures without a visible verdict', () => { + vi.spyOn(config, 'isArbiterEnabled').mockReturnValue(true); + vi.mocked(db.getPairedTaskById).mockReturnValue( + buildPairedTask({ + status: 'review_ready', + reviewer_failure_count: 1, + }), + ); + + completePairedExecutionContext({ + taskId: 'task-1', + role: 'reviewer', + status: 'failed', + summary: 'runtime exploded before verdict', + }); + + expect(db.updatePairedTask).toHaveBeenCalledWith( + 'task-1', + expect.objectContaining({ + status: 'arbiter_requested', + reviewer_failure_count: 2, + arbiter_requested_at: expect.any(String), + }), + ); + }); + it('keeps arbiter tasks arbiter_requested when arbiter execution fails without a terminal verdict', () => { vi.mocked(db.getPairedTaskById).mockReturnValue( buildPairedTask({ From 7197099db5bcce1d7c1451800c29c1de5e7543da Mon Sep 17 00:00:00 2001 From: claude-bot Date: Mon, 1 Jun 2026 20:44:03 +0900 Subject: [PATCH 09/11] fix(discord): escape prose markdown without clobbering structured output --- src/channels/discord-outbound.ts | 13 ++++++++++++- src/channels/discord.ts | 6 ++++-- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/src/channels/discord-outbound.ts b/src/channels/discord-outbound.ts index cd6ae03..4752ba4 100644 --- a/src/channels/discord-outbound.ts +++ b/src/channels/discord-outbound.ts @@ -1,6 +1,7 @@ import path from 'path'; import { normalizeAgentOutput } from '../agent-protocol.js'; +import { neutralizeStrayMarkdown, sanitizeForOutbound } from '../router.js'; import type { OutboundAttachment } from '../types.js'; const LOCAL_MARKDOWN_LINK_RE = /\[[^\]\n]*\]\((\/[^)\n]+)\)/g; @@ -46,7 +47,17 @@ export function prepareDiscordOutbound( const structuredOutput = normalized.output?.visibility === 'public' ? normalized.output : null; const outboundText = structuredOutput?.text ?? normalized.result ?? text; - const cleanText = sanitizeLocalMarkdownLinks(outboundText); + // Redact/strip before escaping so secret patterns (which contain `_`) still + // match, then escape stray Discord markdown in free-form agent prose so + // emphasis markers (**bold**, ~~strike~~, โ€ฆ) render literally. Author- + // controlled structured envelopes are intentional, so their text is left + // untouched. Escaping runs before sanitizeLocalMarkdownLinks so the inline + // code it deliberately produces is preserved. + const safeText = sanitizeForOutbound(outboundText); + const renderedText = structuredOutput + ? safeText + : neutralizeStrayMarkdown(safeText); + const cleanText = sanitizeLocalMarkdownLinks(renderedText); const attachments = optionAttachments && optionAttachments.length > 0 ? optionAttachments diff --git a/src/channels/discord.ts b/src/channels/discord.ts index 8607998..54fc5b1 100644 --- a/src/channels/discord.ts +++ b/src/channels/discord.ts @@ -15,7 +15,7 @@ import { CACHE_DIR } from '../config.js'; import { getEnv } from '../env.js'; import { logger } from '../logger.js'; import { validateOutboundAttachments } from '../outbound-attachments.js'; -import { formatOutbound } from '../router.js'; +import { sanitizeForOutbound } from '../router.js'; import { hasReviewerLease } from '../service-routing.js'; import type { DeleteRecentMessagesByContentOptions, @@ -528,7 +528,9 @@ export class DiscordChannel implements Channel { for (const [name, id] of Object.entries(mentionMap)) { cleaned = cleaned.replace(new RegExp(`@${name}`, 'g'), `<@${id}>`); } - cleaned = formatOutbound(cleaned); + // Markdown escaping already happened in prepareDiscordOutbound (before + // the deliberate linkโ†’inline-code step). Here we only strip/redact. + cleaned = sanitizeForOutbound(cleaned); // Discord has a 2000 character limit per message and 10 attachments per message const MAX_LENGTH = 2000; From 2d4b136c00733d7db4bcfca2af4007db7122e951 Mon Sep 17 00:00:00 2001 From: claude-bot Date: Mon, 1 Jun 2026 20:44:09 +0900 Subject: [PATCH 10/11] docs(readme): document Gitea deployment and operational adaptations --- README.md | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 34b1658..9571c9f 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,8 @@ EJClaw๋Š” Discord ์œ„์—์„œ ๋™์ž‘ํ•˜๋Š” Tribunal ๋ฉ€ํ‹ฐ์—์ด์ „ํŠธ ๊ฐœ๋ฐœ ๋ณด ์›๋ณธ์€ [qwibitai/nanoclaw](https://github.com/qwibitai/nanoclaw)์—์„œ ์ถœ๋ฐœํ–ˆ์ง€๋งŒ, ํ˜„์žฌ๋Š” EJClaw์˜ Discord/paired-runtime ๊ตฌ์กฐ์— ๋งž๊ฒŒ ๋…๋ฆฝ์ ์œผ๋กœ ์œ ์ง€๋˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค. +์ด Gitea ์ €์žฅ์†Œ๋Š” GitHub `main`์„ ๋ฒ ์ด์Šค๋กœ ์‚ผ๊ณ , ์šด์˜ ํ™˜๊ฒฝ์—์„œ ๊ฒ€์ฆ๋œ ๋กœ์ปฌ ๋ณ€๊ฒฝ(์•„๋ž˜ "์šด์˜ ์ ์šฉ ์‚ฌํ•ญ")์„ ํ˜„์žฌ ์ฝ”๋“œ ๊ตฌ์กฐ์— ๋งž๊ฒŒ ์žฌ๊ตฌํ˜„ํ•ด ์–น์€ ๋ฐฐํฌ๋ณธ์ž…๋‹ˆ๋‹ค. + ## ๊ฐœ์š” - ๋‹จ์ผ `ejclaw` ์„œ๋น„์Šค๊ฐ€ owner / reviewer / arbiter ์„ธ ์—ญํ• ๊ณผ ์„ธ Discord ๋ด‡์„ ํ•จ๊ป˜ ๊ด€๋ฆฌํ•ฉ๋‹ˆ๋‹ค. @@ -32,6 +34,16 @@ EJClaw๋Š” Discord ์œ„์—์„œ ๋™์ž‘ํ•˜๋Š” Tribunal ๋ฉ€ํ‹ฐ์—์ด์ „ํŠธ ๊ฐœ๋ฐœ ๋ณด - `assign_room` ๊ธฐ๋ฐ˜ ๋ช…์‹œ์  room assignment - Bun + SQLite ๊ธฐ๋ฐ˜ ๋น ๋ฅธ ๋Ÿฐํƒ€์ž„ +## ์šด์˜ ์ ์šฉ ์‚ฌํ•ญ + +GitHub `main` ๋ฒ ์ด์Šค ์œ„์— ์šด์˜ ์ค‘ ๊ฒ€์ฆ๋œ ๋ณ€๊ฒฝ์„ ํ˜„์žฌ ์ฝ”๋“œ ๊ตฌ์กฐ์— ๋งž์ถฐ ์žฌ๊ตฌํ˜„ํ•ด ๋ฐ˜์˜ํ–ˆ์Šต๋‹ˆ๋‹ค. + +- Discord ์ถœ๋ ฅ ๋งˆํฌ๋‹ค์šด ๋ณด์กด: ์ผ๋ฐ˜ ๋ฌธ์žฅ์˜ `**bold**`, `~~strike~~`, ๋ฐฑํ‹ฑ ๋“ฑ emphasis ๋งˆ์ปค๋ฅผ ๋ Œ๋”๋ง ๋Œ€์‹  ์›๋ฌธ ๊ทธ๋Œ€๋กœ ํ‘œ์‹œ(๋ฐฑ์Šฌ๋ž˜์‹œ ์ด์Šค์ผ€์ดํ”„). ์ฝ”๋“œ๋ธ”๋ก๊ณผ ๊ตฌ์กฐํ™” ์ถœ๋ ฅ(EJClaw JSON, ๋งํฌโ†’์ธ๋ผ์ธ ์ฝ”๋“œ)์€ ์˜๋„๋Œ€๋กœ ๋ณด์กดํ•˜๊ณ , ์ด์Šค์ผ€์ดํ”„๋Š” Discord ์ „์†ก ์ง์ „ ํ•œ ๋ฒˆ๋งŒ ์ ์šฉ๋ฉ๋‹ˆ๋‹ค. +- 2000์ž ๋ถ„ํ•  ์‹œ ์ฝ”๋“œ๋ธ”๋ก ๋ณด์กด: ์ฝ”๋“œ ํŽœ์Šค(triple backtick)๋ฅผ ์ธ์‹ํ•ด ์ฒญํฌ ๊ฒฝ๊ณ„์—์„œ ์ฝ”๋“œ๋ธ”๋ก์ด ๊นจ์ง€์ง€ ์•Š๋„๋ก ๋ถ„ํ• (์„œ๋Ÿฌ๊ฒŒ์ดํŠธ ํŽ˜์–ด ์•ˆ์ „ ์ฒ˜๋ฆฌ ํฌํ•จ). +- ์‚ฌ์šฉ๋Ÿ‰ ์œˆ๋„์šฐ ์ •๋ ฌ ํ”„๋ผ์ด๋จธ: Codex/Claude ์ดˆ๊ธฐํ™” ์‹œ์ ์„ ๋งž์ถ”๊ธฐ ์œ„ํ•œ ์ •๋ ฌ ์‹ ํ˜ธ๋ฅผ ์กฐ๊ฑด ์—†์ด ๋ฐœ์‚ฌ. +- ๋ฆฌ๋ทฐ์–ด ๋ฌด์‘๋‹ต ๋ฐฉ์ง€: ๋ฆฌ๋ทฐ์–ด๊ฐ€ verdict ์—†์ด ์—ฐ์† ์‹คํŒจํ•  ๋•Œ ์žฌ์‹œ๋„ ์ƒํ•œ(๊ธฐ๋ณธ 2ํšŒ)์œผ๋กœ ํ•‘ํ ๋ฃจํ”„๋ฅผ ๋Š๊ณ  arbiter/์‚ฌ์šฉ์ž๋กœ ์—์Šค์ปฌ๋ ˆ์ด์…˜. +- ๋Œ€์‹œ๋ณด๋“œ: ๋“ฑ๋ก๋œ ๋ฐฉ ๋ณ„์นญ์„ Discord ์ฑ„๋„๋ช…๊ณผ ๋ถ„๋ฆฌํ•ด ๋ณด์กด. + ## Tribunal ์‹œ์Šคํ…œ | ์—ญํ•  | ํ˜„์žฌ ๊ธฐ๋ณธ๊ฐ’ | ์„ค๋ช… | @@ -123,7 +135,7 @@ Discord โ”€โ”€โ–บ SQLite (WAL) โ”€โ”€โ–บ GroupQueue โ”€โ”€โ”ฌโ”€โ”€โ–บ Owner (ho ### ์„ค์น˜ ```bash -git clone https://github.com/phj1081/EJClaw.git +git clone https://git.tkrmagid.kr/claude-bot/EJClaw.git cd EJClaw bun install bun run build:all From 453157f6c36d97bf52167d55d49649b7882d7233 Mon Sep 17 00:00:00 2001 From: claude-bot Date: Mon, 1 Jun 2026 21:08:45 +0900 Subject: [PATCH 11/11] fix(db): backfill turn_progress_text columns for v15 migration collision Deployments that shipped reviewer_failure_count as a local migration numbered 15 record schema_migrations version 15 under that name, so the version-number-only runner skips the canonical turn_progress_text migration (also version 15) and never creates paired_turns.progress_text / progress_updated_at, which the runtime reads. Add an idempotent compat migration (020) that re-adds the columns when missing, plus a regression test reproducing the collided-version-15 database. Co-Authored-By: Claude Opus 4 --- src/db/bootstrap.test.ts | 61 +++++++++++++++++++ .../020_turn-progress-text-compat.ts | 32 ++++++++++ src/db/migrations/index.ts | 2 + 3 files changed, 95 insertions(+) create mode 100644 src/db/migrations/020_turn-progress-text-compat.ts diff --git a/src/db/bootstrap.test.ts b/src/db/bootstrap.test.ts index ad288de..7bb7176 100644 --- a/src/db/bootstrap.test.ts +++ b/src/db/bootstrap.test.ts @@ -6,8 +6,17 @@ import { afterEach, describe, expect, it } from 'vitest'; import { applyBaseSchema } from './base-schema.js'; import { initializeDatabaseSchema } from './bootstrap.js'; +import { applyVersionedSchemaMigrations } from './migrations/index.js'; import { applyLegacySchemaMigrations } from './schema.js'; +function tableColumns(database: Database, table: string): string[] { + return ( + database.prepare(`PRAGMA table_info(${table})`).all() as Array<{ + name: string; + }> + ).map((column) => column.name); +} + function getAppliedSchemaMigrations( database: Database, ): Array<{ version: number; name: string }> { @@ -44,6 +53,7 @@ function getExpectedSchemaMigrations(): Array<{ { version: 17, name: 'scheduled_task_room_role' }, { version: 18, name: 'paired_turn_output_attachments' }, { version: 19, name: 'reviewer_failure_count' }, + { version: 20, name: 'turn_progress_text_compat' }, ]; } @@ -114,4 +124,55 @@ describe('initializeDatabaseSchema', () => { reopened.close(); } }); + + it('backfills turn_progress_text columns when version 15 was recorded under a different name', () => { + const database = new Database(':memory:'); + + try { + applyBaseSchema(database); + + // Reproduce a deployment that first shipped reviewer_failure_count as a + // local migration numbered 15, before turn_progress_text claimed that + // version upstream. The runner skips by version number, so migration 015 + // (turn_progress_text) would otherwise never run on this database. + database.exec(` + CREATE TABLE schema_migrations ( + version INTEGER PRIMARY KEY, + name TEXT NOT NULL, + applied_at TEXT NOT NULL DEFAULT (datetime('now')) + ) + `); + const collidedNames: Record = { + 15: 'reviewer_failure_count', + }; + for (let version = 1; version <= 15; version += 1) { + database + .prepare('INSERT INTO schema_migrations (version, name) VALUES (?, ?)') + .run(version, collidedNames[version] ?? `legacy_${version}`); + } + // Precondition: the collided database is missing the progress columns. + expect(tableColumns(database, 'paired_turns')).not.toContain( + 'progress_text', + ); + + applyVersionedSchemaMigrations(database, { assistantName: 'Andy' }); + + const columns = tableColumns(database, 'paired_turns'); + expect(columns).toContain('progress_text'); + expect(columns).toContain('progress_updated_at'); + + // The pre-existing version-15 row is left untouched; the compat migration + // (version 20) is what reconciles the schema. + const version15 = database + .prepare('SELECT name FROM schema_migrations WHERE version = 15') + .get() as { name: string }; + expect(version15.name).toBe('reviewer_failure_count'); + const version20 = database + .prepare('SELECT name FROM schema_migrations WHERE version = 20') + .get() as { name: string } | null; + expect(version20?.name).toBe('turn_progress_text_compat'); + } finally { + database.close(); + } + }); }); diff --git a/src/db/migrations/020_turn-progress-text-compat.ts b/src/db/migrations/020_turn-progress-text-compat.ts new file mode 100644 index 0000000..37b501c --- /dev/null +++ b/src/db/migrations/020_turn-progress-text-compat.ts @@ -0,0 +1,32 @@ +import type { Database } from 'bun:sqlite'; + +import { tableHasColumn } from './helpers.js'; +import type { SchemaMigrationDefinition } from './types.js'; + +/** + * Compatibility backfill for `turn_progress_text` (migration 015). + * + * Some deployments first shipped `reviewer_failure_count` as a local migration + * numbered 15, so their `schema_migrations` table records version 15 under a + * different name than the canonical `turn_progress_text` migration. The runner + * skips migrations purely by version number, so on those databases the real + * version-15 migration never runs and `paired_turns.progress_text` / + * `progress_updated_at` are missing โ€” yet the runtime reads them. This migration + * re-adds the columns idempotently so collided databases converge with fresh + * ones. On a fresh database migration 015 already created the columns, so the + * `tableHasColumn` guards make this a no-op. + */ +export const TURN_PROGRESS_TEXT_COMPAT_MIGRATION: SchemaMigrationDefinition = { + version: 20, + name: 'turn_progress_text_compat', + apply(database: Database) { + if (!tableHasColumn(database, 'paired_turns', 'progress_text')) { + database.exec(`ALTER TABLE paired_turns ADD COLUMN progress_text TEXT`); + } + if (!tableHasColumn(database, 'paired_turns', 'progress_updated_at')) { + database.exec( + `ALTER TABLE paired_turns ADD COLUMN progress_updated_at TEXT`, + ); + } + }, +}; diff --git a/src/db/migrations/index.ts b/src/db/migrations/index.ts index ebf67e5..4079e38 100644 --- a/src/db/migrations/index.ts +++ b/src/db/migrations/index.ts @@ -19,6 +19,7 @@ import { ROOM_SKILL_OVERRIDES_MIGRATION } from './016_room-skill-overrides.js'; import { SCHEDULED_TASK_ROOM_ROLE_MIGRATION } from './017_scheduled-task-room-role.js'; import { PAIRED_TURN_OUTPUT_ATTACHMENTS_MIGRATION } from './018_paired-turn-output-attachments.js'; import { REVIEWER_FAILURE_COUNT_MIGRATION } from './019_reviewer-failure-count.js'; +import { TURN_PROGRESS_TEXT_COMPAT_MIGRATION } from './020_turn-progress-text-compat.js'; import type { SchemaMigrationArgs, SchemaMigrationDefinition, @@ -46,6 +47,7 @@ const ORDERED_SCHEMA_MIGRATIONS: readonly SchemaMigrationDefinition[] = [ SCHEDULED_TASK_ROOM_ROLE_MIGRATION, PAIRED_TURN_OUTPUT_ATTACHMENTS_MIGRATION, REVIEWER_FAILURE_COUNT_MIGRATION, + TURN_PROGRESS_TEXT_COMPAT_MIGRATION, ]; function ensureSchemaMigrationsTable(database: Database): void {