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:
Codex
2026-06-18 05:48:50 +09:00
parent e14ac3dfca
commit 822ac34c0e
5 changed files with 11 additions and 137 deletions

View File

@@ -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);
});
});

View File

@@ -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

View File

@@ -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();
}

View File

@@ -191,7 +191,7 @@ export function createPairedTaskInDatabase(
created_at,
updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
)
.run(

View File

@@ -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';