Merge remote-tracking branch 'gitea/main'
# Conflicts: # prompts/owner-common-paired-room.md # src/channels/discord.ts # src/codex-warmup.ts # src/db/bootstrap.test.ts # src/db/migrations/index.ts # src/paired-execution-context-reviewer.ts # src/usage-primer.test.ts # src/usage-primer.ts
This commit is contained in:
14
README.md
14
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
|
||||
|
||||
@@ -68,7 +68,6 @@ Every finalize message must contain, in this order:
|
||||
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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ---
|
||||
@@ -1160,3 +1164,127 @@ 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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 }> {
|
||||
@@ -45,6 +54,8 @@ function getExpectedSchemaMigrations(): Array<{
|
||||
{ version: 18, name: 'paired_turn_output_attachments' },
|
||||
{ version: 19, name: 'turn_progress_text_recovery' },
|
||||
{ version: 20, name: 'arbiter_intervention_count' },
|
||||
{ version: 21, name: 'reviewer_failure_count' },
|
||||
{ version: 22, name: 'turn_progress_text_compat' },
|
||||
];
|
||||
}
|
||||
|
||||
@@ -115,4 +126,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<number, string> = {
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
23
src/db/migrations/021_reviewer-failure-count.ts
Normal file
23
src/db/migrations/021_reviewer-failure-count.ts
Normal file
@@ -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: 21,
|
||||
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
|
||||
`);
|
||||
},
|
||||
};
|
||||
32
src/db/migrations/022_turn-progress-text-compat.ts
Normal file
32
src/db/migrations/022_turn-progress-text-compat.ts
Normal file
@@ -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: 22,
|
||||
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`,
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -20,6 +20,8 @@ import { SCHEDULED_TASK_ROOM_ROLE_MIGRATION } from './017_scheduled-task-room-ro
|
||||
import { PAIRED_TURN_OUTPUT_ATTACHMENTS_MIGRATION } from './018_paired-turn-output-attachments.js';
|
||||
import { TURN_PROGRESS_TEXT_RECOVERY_MIGRATION } from './019_turn-progress-text-recovery.js';
|
||||
import { ARBITER_INTERVENTION_COUNT_MIGRATION } from './020_arbiter-intervention-count.js';
|
||||
import { REVIEWER_FAILURE_COUNT_MIGRATION } from './021_reviewer-failure-count.js';
|
||||
import { TURN_PROGRESS_TEXT_COMPAT_MIGRATION } from './022_turn-progress-text-compat.js';
|
||||
import type {
|
||||
SchemaMigrationArgs,
|
||||
SchemaMigrationDefinition,
|
||||
@@ -48,6 +50,8 @@ const ORDERED_SCHEMA_MIGRATIONS: readonly SchemaMigrationDefinition[] = [
|
||||
PAIRED_TURN_OUTPUT_ATTACHMENTS_MIGRATION,
|
||||
TURN_PROGRESS_TEXT_RECOVERY_MIGRATION,
|
||||
ARBITER_INTERVENTION_COUNT_MIGRATION,
|
||||
REVIEWER_FAILURE_COUNT_MIGRATION,
|
||||
TURN_PROGRESS_TEXT_COMPAT_MIGRATION,
|
||||
];
|
||||
|
||||
function ensureSchemaMigrationsTable(database: Database): void {
|
||||
|
||||
@@ -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'
|
||||
@@ -177,6 +178,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,
|
||||
@@ -207,6 +209,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,
|
||||
@@ -342,6 +345,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);
|
||||
@@ -425,6 +432,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);
|
||||
|
||||
@@ -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,
|
||||
@@ -55,6 +55,7 @@ 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';
|
||||
@@ -99,7 +100,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 +116,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 +147,7 @@ async function deliverFormattedCanonicalMessage(
|
||||
rawText: string,
|
||||
deliveryRole?: 'owner' | 'reviewer' | 'arbiter',
|
||||
): Promise<void> {
|
||||
const text = formatOutbound(rawText);
|
||||
const text = sanitizeForOutbound(rawText);
|
||||
if (!text) return;
|
||||
await deliverCanonicalOutboundMessage(
|
||||
{ jid, text, deliveryRole },
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -25,6 +25,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;
|
||||
|
||||
@@ -399,6 +399,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,
|
||||
|
||||
@@ -3,7 +3,10 @@ import { getPairedWorkspace } from './db.js';
|
||||
import { isTerminalCodexAccountFailure } from './agent-error-detection.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,
|
||||
@@ -12,6 +15,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;
|
||||
@@ -104,6 +118,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'
|
||||
@@ -115,17 +158,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: {
|
||||
@@ -157,6 +213,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,
|
||||
@@ -194,6 +251,7 @@ export function handleReviewerCompletion(args: {
|
||||
escalateLogMessage,
|
||||
logContext: { taskId, verdict, summary: summary?.slice(0, 100) },
|
||||
arbiterInterventionCount: task.arbiter_intervention_count ?? 0,
|
||||
patch: { reviewer_failure_count: 0 },
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -205,6 +263,7 @@ export function handleReviewerCompletion(args: {
|
||||
nextStatus: 'active',
|
||||
expectedUpdatedAt: task.updated_at,
|
||||
updatedAt: now,
|
||||
patch: { reviewer_failure_count: 0 },
|
||||
});
|
||||
logger.info(
|
||||
{ taskId, verdict },
|
||||
|
||||
@@ -143,6 +143,7 @@ function buildPairedTask(overrides: Partial<PairedTask> = {}): 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,
|
||||
@@ -1398,7 +1399,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',
|
||||
@@ -1416,11 +1417,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({
|
||||
|
||||
@@ -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,
|
||||
@@ -493,6 +494,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,
|
||||
arbiter_intervention_count: 0,
|
||||
@@ -510,6 +512,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,
|
||||
arbiter_intervention_count: 0,
|
||||
|
||||
@@ -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;
|
||||
@@ -111,6 +112,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;
|
||||
|
||||
@@ -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\\*');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) : '';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user