fix(merge): integrate gitea/main fork — renumber migrations, dedup, fix paired_tasks insert
Resolves the gitea/main <-> deployed-line merge: - DB migrations: renumber gitea's colliding 019/020 to 021/022 (reviewer_failure_count -> v21, turn_progress_text_compat -> v22) so all four migrations have distinct versions; update ordered list + bootstrap test. - paired_tasks INSERT: add the missing VALUES placeholder so both new columns (reviewer_failure_count + arbiter_intervention_count) bind (26 cols/values). - index.ts: drop duplicate startUsagePrimer import from the auto-merge. - discord output: keep the deployed pipeline (attachment rejection notice) and call sanitizeForOutbound at the channel boundary so prose escaping done in prepareDiscordOutbound is not double-applied; keep gitea's reviewer silent-failure cap + router markdown-escape helpers. - usage-primer/codex-warmup: keep the dawn-hold removal over gitea's primer. Full test suite: only pre-existing env/bun-path failures remain; no merge regressions. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -129,11 +129,7 @@ vi.mock('discord.js', () => {
|
||||
};
|
||||
});
|
||||
|
||||
import {
|
||||
DiscordChannel,
|
||||
DiscordChannelOpts,
|
||||
chunkForDiscord,
|
||||
} from './discord.js';
|
||||
import { DiscordChannel, DiscordChannelOpts } from './discord.js';
|
||||
import { logger } from '../logger.js';
|
||||
|
||||
// --- Test helpers ---
|
||||
@@ -1164,127 +1160,3 @@ 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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
appendRejectionNotice,
|
||||
validateOutboundAttachments,
|
||||
} from '../outbound-attachments.js';
|
||||
import { formatOutbound } from '../router.js';
|
||||
import { sanitizeForOutbound } from '../router.js';
|
||||
import { hasReviewerLease } from '../service-routing.js';
|
||||
import type {
|
||||
DeleteRecentMessagesByContentOptions,
|
||||
@@ -419,7 +419,10 @@ export class DiscordChannel implements Channel {
|
||||
|
||||
// Convert @username mentions to Discord mention format
|
||||
cleaned = cleaned.replaceAll('@눈쟁이', '<@216851709744513024>');
|
||||
cleaned = formatOutbound(cleaned);
|
||||
// Markdown escaping already happened in prepareDiscordOutbound (prose
|
||||
// escaped, structured output / fenced code preserved). Here we only
|
||||
// re-sanitize so we don't double-escape the delimiters.
|
||||
cleaned = sanitizeForOutbound(cleaned);
|
||||
|
||||
// Surface rejected attachments in the visible message. The MEDIA:
|
||||
// directive was already stripped from the text, so without this the user
|
||||
|
||||
@@ -164,15 +164,15 @@ describe('initializeDatabaseSchema', () => {
|
||||
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.
|
||||
// (version 22 after merge-renumbering) 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')
|
||||
const version22 = database
|
||||
.prepare('SELECT name FROM schema_migrations WHERE version = 22')
|
||||
.get() as { name: string } | null;
|
||||
expect(version20?.name).toBe('turn_progress_text_compat');
|
||||
expect(version22?.name).toBe('turn_progress_text_compat');
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
|
||||
@@ -191,7 +191,7 @@ export function createPairedTaskInDatabase(
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
)
|
||||
.run(
|
||||
|
||||
@@ -55,7 +55,6 @@ import { nudgeSchedulerLoop, startSchedulerLoop } from './task-scheduler.js';
|
||||
import { startUnifiedDashboard } from './unified-dashboard.js';
|
||||
import { startUsagePrimer } from './usage-primer.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';
|
||||
|
||||
Reference in New Issue
Block a user