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 구조에 맞게 독립적으로 유지되고 있습니다.
|
원본은 [qwibitai/nanoclaw](https://github.com/qwibitai/nanoclaw)에서 출발했지만, 현재는 EJClaw의 Discord/paired-runtime 구조에 맞게 독립적으로 유지되고 있습니다.
|
||||||
|
|
||||||
|
이 Gitea 저장소는 GitHub `main`을 베이스로 삼고, 운영 환경에서 검증된 로컬 변경(아래 "운영 적용 사항")을 현재 코드 구조에 맞게 재구현해 얹은 배포본입니다.
|
||||||
|
|
||||||
## 개요
|
## 개요
|
||||||
|
|
||||||
- 단일 `ejclaw` 서비스가 owner / reviewer / arbiter 세 역할과 세 Discord 봇을 함께 관리합니다.
|
- 단일 `ejclaw` 서비스가 owner / reviewer / arbiter 세 역할과 세 Discord 봇을 함께 관리합니다.
|
||||||
@@ -32,6 +34,16 @@ EJClaw는 Discord 위에서 동작하는 Tribunal 멀티에이전트 개발 보
|
|||||||
- `assign_room` 기반 명시적 room assignment
|
- `assign_room` 기반 명시적 room assignment
|
||||||
- Bun + SQLite 기반 빠른 런타임
|
- Bun + SQLite 기반 빠른 런타임
|
||||||
|
|
||||||
|
## 운영 적용 사항
|
||||||
|
|
||||||
|
GitHub `main` 베이스 위에 운영 중 검증된 변경을 현재 코드 구조에 맞춰 재구현해 반영했습니다.
|
||||||
|
|
||||||
|
- Discord 출력 마크다운 보존: 일반 문장의 `**bold**`, `~~strike~~`, 백틱 등 emphasis 마커를 렌더링 대신 원문 그대로 표시(백슬래시 이스케이프). 코드블록과 구조화 출력(EJClaw JSON, 링크→인라인 코드)은 의도대로 보존하고, 이스케이프는 Discord 전송 직전 한 번만 적용됩니다.
|
||||||
|
- 2000자 분할 시 코드블록 보존: 코드 펜스(triple backtick)를 인식해 청크 경계에서 코드블록이 깨지지 않도록 분할(서러게이트 페어 안전 처리 포함).
|
||||||
|
- 사용량 윈도우 정렬 프라이머: Codex/Claude 초기화 시점을 맞추기 위한 정렬 신호를 조건 없이 발사.
|
||||||
|
- 리뷰어 무응답 방지: 리뷰어가 verdict 없이 연속 실패할 때 재시도 상한(기본 2회)으로 핑퐁 루프를 끊고 arbiter/사용자로 에스컬레이션.
|
||||||
|
- 대시보드: 등록된 방 별칭을 Discord 채널명과 분리해 보존.
|
||||||
|
|
||||||
## Tribunal 시스템
|
## Tribunal 시스템
|
||||||
|
|
||||||
| 역할 | 현재 기본값 | 설명 |
|
| 역할 | 현재 기본값 | 설명 |
|
||||||
@@ -123,7 +135,7 @@ Discord ──► SQLite (WAL) ──► GroupQueue ──┬──► Owner (ho
|
|||||||
### 설치
|
### 설치
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/phj1081/EJClaw.git
|
git clone https://git.tkrmagid.kr/claude-bot/EJClaw.git
|
||||||
cd EJClaw
|
cd EJClaw
|
||||||
bun install
|
bun install
|
||||||
bun run build:all
|
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.
|
5. **(Optional) Follow-up suggestions** — only if genuinely useful, kept short.
|
||||||
|
|
||||||
Do **not** include:
|
Do **not** include:
|
||||||
|
|
||||||
- "리뷰어가 지적해서 정정했습니다", "PROCEED 확정", "리뷰어 피드백 반영" 같은 메타 문구. The user doesn't know there was a reviewer.
|
- "리뷰어가 지적해서 정정했습니다", "PROCEED 확정", "리뷰어 피드백 반영" 같은 메타 문구. The user doesn't know there was a reviewer.
|
||||||
- A blow-by-blow of each round.
|
- A blow-by-blow of each round.
|
||||||
- Apologies for earlier owner mistakes that the user never saw.
|
- Apologies for earlier owner mistakes that the user never saw.
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
import { normalizeAgentOutput } from '../agent-protocol.js';
|
import { normalizeAgentOutput } from '../agent-protocol.js';
|
||||||
|
import { neutralizeStrayMarkdown, sanitizeForOutbound } from '../router.js';
|
||||||
import type { OutboundAttachment } from '../types.js';
|
import type { OutboundAttachment } from '../types.js';
|
||||||
|
|
||||||
const LOCAL_MARKDOWN_LINK_RE = /\[[^\]\n]*\]\((\/[^)\n]+)\)/g;
|
const LOCAL_MARKDOWN_LINK_RE = /\[[^\]\n]*\]\((\/[^)\n]+)\)/g;
|
||||||
@@ -46,7 +47,17 @@ export function prepareDiscordOutbound(
|
|||||||
const structuredOutput =
|
const structuredOutput =
|
||||||
normalized.output?.visibility === 'public' ? normalized.output : null;
|
normalized.output?.visibility === 'public' ? normalized.output : null;
|
||||||
const outboundText = structuredOutput?.text ?? normalized.result ?? text;
|
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 =
|
const attachments =
|
||||||
optionAttachments && optionAttachments.length > 0
|
optionAttachments && optionAttachments.length > 0
|
||||||
? optionAttachments
|
? 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';
|
import { logger } from '../logger.js';
|
||||||
|
|
||||||
// --- Test helpers ---
|
// --- Test helpers ---
|
||||||
@@ -1160,3 +1164,127 @@ describe('channel properties', () => {
|
|||||||
expect(channel.name).toBe('discord');
|
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 { applyBaseSchema } from './base-schema.js';
|
||||||
import { initializeDatabaseSchema } from './bootstrap.js';
|
import { initializeDatabaseSchema } from './bootstrap.js';
|
||||||
|
import { applyVersionedSchemaMigrations } from './migrations/index.js';
|
||||||
import { applyLegacySchemaMigrations } from './schema.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(
|
function getAppliedSchemaMigrations(
|
||||||
database: Database,
|
database: Database,
|
||||||
): Array<{ version: number; name: string }> {
|
): Array<{ version: number; name: string }> {
|
||||||
@@ -45,6 +54,8 @@ function getExpectedSchemaMigrations(): Array<{
|
|||||||
{ version: 18, name: 'paired_turn_output_attachments' },
|
{ version: 18, name: 'paired_turn_output_attachments' },
|
||||||
{ version: 19, name: 'turn_progress_text_recovery' },
|
{ version: 19, name: 'turn_progress_text_recovery' },
|
||||||
{ version: 20, name: 'arbiter_intervention_count' },
|
{ 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();
|
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 { 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 { TURN_PROGRESS_TEXT_RECOVERY_MIGRATION } from './019_turn-progress-text-recovery.js';
|
||||||
import { ARBITER_INTERVENTION_COUNT_MIGRATION } from './020_arbiter-intervention-count.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 {
|
import type {
|
||||||
SchemaMigrationArgs,
|
SchemaMigrationArgs,
|
||||||
SchemaMigrationDefinition,
|
SchemaMigrationDefinition,
|
||||||
@@ -48,6 +50,8 @@ const ORDERED_SCHEMA_MIGRATIONS: readonly SchemaMigrationDefinition[] = [
|
|||||||
PAIRED_TURN_OUTPUT_ATTACHMENTS_MIGRATION,
|
PAIRED_TURN_OUTPUT_ATTACHMENTS_MIGRATION,
|
||||||
TURN_PROGRESS_TEXT_RECOVERY_MIGRATION,
|
TURN_PROGRESS_TEXT_RECOVERY_MIGRATION,
|
||||||
ARBITER_INTERVENTION_COUNT_MIGRATION,
|
ARBITER_INTERVENTION_COUNT_MIGRATION,
|
||||||
|
REVIEWER_FAILURE_COUNT_MIGRATION,
|
||||||
|
TURN_PROGRESS_TEXT_COMPAT_MIGRATION,
|
||||||
];
|
];
|
||||||
|
|
||||||
function ensureSchemaMigrationsTable(database: Database): void {
|
function ensureSchemaMigrationsTable(database: Database): void {
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ export type PairedTaskUpdates = Partial<
|
|||||||
| 'review_requested_at'
|
| 'review_requested_at'
|
||||||
| 'round_trip_count'
|
| 'round_trip_count'
|
||||||
| 'owner_failure_count'
|
| 'owner_failure_count'
|
||||||
|
| 'reviewer_failure_count'
|
||||||
| 'owner_step_done_streak'
|
| 'owner_step_done_streak'
|
||||||
| 'finalize_step_done_count'
|
| 'finalize_step_done_count'
|
||||||
| 'task_done_then_user_reopen_count'
|
| 'task_done_then_user_reopen_count'
|
||||||
@@ -177,6 +178,7 @@ export function createPairedTaskInDatabase(
|
|||||||
review_requested_at,
|
review_requested_at,
|
||||||
round_trip_count,
|
round_trip_count,
|
||||||
owner_failure_count,
|
owner_failure_count,
|
||||||
|
reviewer_failure_count,
|
||||||
owner_step_done_streak,
|
owner_step_done_streak,
|
||||||
finalize_step_done_count,
|
finalize_step_done_count,
|
||||||
task_done_then_user_reopen_count,
|
task_done_then_user_reopen_count,
|
||||||
@@ -207,6 +209,7 @@ export function createPairedTaskInDatabase(
|
|||||||
task.review_requested_at,
|
task.review_requested_at,
|
||||||
task.round_trip_count,
|
task.round_trip_count,
|
||||||
task.owner_failure_count ?? 0,
|
task.owner_failure_count ?? 0,
|
||||||
|
task.reviewer_failure_count ?? 0,
|
||||||
task.owner_step_done_streak ?? 0,
|
task.owner_step_done_streak ?? 0,
|
||||||
task.finalize_step_done_count ?? 0,
|
task.finalize_step_done_count ?? 0,
|
||||||
task.task_done_then_user_reopen_count ?? 0,
|
task.task_done_then_user_reopen_count ?? 0,
|
||||||
@@ -342,6 +345,10 @@ export function updatePairedTaskInDatabase(
|
|||||||
fields.push('owner_failure_count = ?');
|
fields.push('owner_failure_count = ?');
|
||||||
values.push(updates.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) {
|
if (updates.owner_step_done_streak !== undefined) {
|
||||||
fields.push('owner_step_done_streak = ?');
|
fields.push('owner_step_done_streak = ?');
|
||||||
values.push(updates.owner_step_done_streak);
|
values.push(updates.owner_step_done_streak);
|
||||||
@@ -425,6 +432,10 @@ export function updatePairedTaskIfUnchangedInDatabase(
|
|||||||
fields.push('owner_failure_count = ?');
|
fields.push('owner_failure_count = ?');
|
||||||
values.push(updates.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) {
|
if (updates.owner_step_done_streak !== undefined) {
|
||||||
fields.push('owner_step_done_streak = ?');
|
fields.push('owner_step_done_streak = ?');
|
||||||
values.push(updates.owner_step_done_streak);
|
values.push(updates.owner_step_done_streak);
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ import {
|
|||||||
deliverCanonicalOutboundMessage,
|
deliverCanonicalOutboundMessage,
|
||||||
deliverIpcOutboundMessage,
|
deliverIpcOutboundMessage,
|
||||||
} from './ipc-outbound-delivery.js';
|
} from './ipc-outbound-delivery.js';
|
||||||
import { findChannel, formatOutbound } from './router.js';
|
import { findChannel, sanitizeForOutbound } from './router.js';
|
||||||
import {
|
import {
|
||||||
buildRestartAnnouncement,
|
buildRestartAnnouncement,
|
||||||
buildInterruptedRestartAnnouncement,
|
buildInterruptedRestartAnnouncement,
|
||||||
@@ -55,6 +55,7 @@ import { nudgeSchedulerLoop, startSchedulerLoop } from './task-scheduler.js';
|
|||||||
import { startUnifiedDashboard } from './unified-dashboard.js';
|
import { startUnifiedDashboard } from './unified-dashboard.js';
|
||||||
import { startUsagePrimer } from './usage-primer.js';
|
import { startUsagePrimer } from './usage-primer.js';
|
||||||
import { startWebDashboardServer } from './web-dashboard-server.js';
|
import { startWebDashboardServer } from './web-dashboard-server.js';
|
||||||
|
import { startUsagePrimer } from './usage-primer.js';
|
||||||
import { Channel, NewMessage, RegisteredGroup } from './types.js';
|
import { Channel, NewMessage, RegisteredGroup } from './types.js';
|
||||||
import { logger } from './logger.js';
|
import { logger } from './logger.js';
|
||||||
import { initCodexTokenRotation } from './codex-token-rotation.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');
|
logger.warn({ jid }, 'No channel owns JID, cannot send tracked message');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const text = formatOutbound(rawText);
|
const text = sanitizeForOutbound(rawText);
|
||||||
if (!text || !channel.sendAndTrack) return null;
|
if (!text || !channel.sendAndTrack) return null;
|
||||||
return channel.sendAndTrack(jid, text);
|
return channel.sendAndTrack(jid, text);
|
||||||
}
|
}
|
||||||
@@ -115,7 +116,7 @@ export async function editFormattedTrackedChannelMessage(
|
|||||||
logger.warn({ jid }, 'No channel owns JID, cannot edit tracked message');
|
logger.warn({ jid }, 'No channel owns JID, cannot edit tracked message');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const text = formatOutbound(rawText);
|
const text = sanitizeForOutbound(rawText);
|
||||||
if (!text || !channel.editMessage) return;
|
if (!text || !channel.editMessage) return;
|
||||||
await channel.editMessage(jid, messageId, text);
|
await channel.editMessage(jid, messageId, text);
|
||||||
}
|
}
|
||||||
@@ -146,7 +147,7 @@ async function deliverFormattedCanonicalMessage(
|
|||||||
rawText: string,
|
rawText: string,
|
||||||
deliveryRole?: 'owner' | 'reviewer' | 'arbiter',
|
deliveryRole?: 'owner' | 'reviewer' | 'arbiter',
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const text = formatOutbound(rawText);
|
const text = sanitizeForOutbound(rawText);
|
||||||
if (!text) return;
|
if (!text) return;
|
||||||
await deliverCanonicalOutboundMessage(
|
await deliverCanonicalOutboundMessage(
|
||||||
{ jid, text, deliveryRole },
|
{ jid, text, deliveryRole },
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {
|
|||||||
getAgentOutputText,
|
getAgentOutputText,
|
||||||
} from './agent-output.js';
|
} from './agent-output.js';
|
||||||
import { createScopedLogger, logger } from './logger.js';
|
import { createScopedLogger, logger } from './logger.js';
|
||||||
import { formatOutbound } from './router.js';
|
import { sanitizeForOutbound } from './router.js';
|
||||||
import { shouldResetSessionOnAgentFailure } from './session-recovery.js';
|
import { shouldResetSessionOnAgentFailure } from './session-recovery.js';
|
||||||
import { TASK_STATUS_MESSAGE_PREFIX } from './task-watch-status.js';
|
import { TASK_STATUS_MESSAGE_PREFIX } from './task-watch-status.js';
|
||||||
import { formatElapsedKorean } from './utils.js';
|
import { formatElapsedKorean } from './utils.js';
|
||||||
@@ -169,7 +169,10 @@ export class MessageTurnController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const raw = getAgentOutputText(result);
|
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);
|
const attachments = getAgentOutputAttachments(result);
|
||||||
|
|
||||||
if (raw) {
|
if (raw) {
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export function requestArbiterOrEscalate(args: {
|
|||||||
review_requested_at?: string | null;
|
review_requested_at?: string | null;
|
||||||
round_trip_count?: number;
|
round_trip_count?: number;
|
||||||
owner_failure_count?: number;
|
owner_failure_count?: number;
|
||||||
|
reviewer_failure_count?: number;
|
||||||
owner_step_done_streak?: number;
|
owner_step_done_streak?: number;
|
||||||
finalize_step_done_count?: number;
|
finalize_step_done_count?: number;
|
||||||
task_done_then_user_reopen_count?: number;
|
task_done_then_user_reopen_count?: number;
|
||||||
|
|||||||
@@ -399,6 +399,7 @@ function maybeAutoTriggerReviewerAfterOwnerCompletion(args: {
|
|||||||
patch: {
|
patch: {
|
||||||
round_trip_count: task.round_trip_count + 1,
|
round_trip_count: task.round_trip_count + 1,
|
||||||
owner_failure_count: 0,
|
owner_failure_count: 0,
|
||||||
|
reviewer_failure_count: 0,
|
||||||
owner_step_done_streak: 0,
|
owner_step_done_streak: 0,
|
||||||
empty_step_done_streak: 0,
|
empty_step_done_streak: 0,
|
||||||
...args.patch,
|
...args.patch,
|
||||||
|
|||||||
@@ -3,7 +3,10 @@ import { getPairedWorkspace } from './db.js';
|
|||||||
import { isTerminalCodexAccountFailure } from './agent-error-detection.js';
|
import { isTerminalCodexAccountFailure } from './agent-error-detection.js';
|
||||||
import { logger } from './logger.js';
|
import { logger } from './logger.js';
|
||||||
import { requestArbiterOrEscalate } from './paired-arbiter-request.js';
|
import { requestArbiterOrEscalate } from './paired-arbiter-request.js';
|
||||||
import { transitionPairedTaskStatus } from './paired-task-status.js';
|
import {
|
||||||
|
applyPairedTaskPatch,
|
||||||
|
transitionPairedTaskStatus,
|
||||||
|
} from './paired-task-status.js';
|
||||||
import {
|
import {
|
||||||
resolveReviewerCompletionSignal,
|
resolveReviewerCompletionSignal,
|
||||||
resolveReviewerFailureSignal,
|
resolveReviewerFailureSignal,
|
||||||
@@ -12,6 +15,17 @@ import { resolveCanonicalSourceRef } from './paired-source-ref.js';
|
|||||||
import { parseVisibleVerdict } from './paired-verdict.js';
|
import { parseVisibleVerdict } from './paired-verdict.js';
|
||||||
import type { PairedTask } from './types.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: {
|
export function handleFailedReviewerExecution(args: {
|
||||||
task: PairedTask;
|
task: PairedTask;
|
||||||
taskId: string;
|
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 =
|
const fallbackStatus =
|
||||||
task.status === 'in_review' || task.status === 'review_ready'
|
task.status === 'in_review' || task.status === 'review_ready'
|
||||||
? 'review_ready'
|
? 'review_ready'
|
||||||
@@ -115,17 +158,30 @@ export function handleFailedReviewerExecution(args: {
|
|||||||
nextStatus: fallbackStatus,
|
nextStatus: fallbackStatus,
|
||||||
expectedUpdatedAt: task.updated_at,
|
expectedUpdatedAt: task.updated_at,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
});
|
patch: {
|
||||||
logger.warn(
|
reviewer_failure_count: nextFailureCount,
|
||||||
{
|
|
||||||
taskId,
|
|
||||||
role: 'reviewer',
|
|
||||||
previousStatus: task.status,
|
|
||||||
nextStatus: fallbackStatus,
|
|
||||||
},
|
},
|
||||||
'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: {
|
export function handleReviewerCompletion(args: {
|
||||||
@@ -157,6 +213,7 @@ export function handleReviewerCompletion(args: {
|
|||||||
patch: {
|
patch: {
|
||||||
source_ref: approvedSourceRef,
|
source_ref: approvedSourceRef,
|
||||||
owner_failure_count: 0,
|
owner_failure_count: 0,
|
||||||
|
reviewer_failure_count: 0,
|
||||||
owner_step_done_streak: 0,
|
owner_step_done_streak: 0,
|
||||||
finalize_step_done_count: 0,
|
finalize_step_done_count: 0,
|
||||||
empty_step_done_streak: 0,
|
empty_step_done_streak: 0,
|
||||||
@@ -194,6 +251,7 @@ export function handleReviewerCompletion(args: {
|
|||||||
escalateLogMessage,
|
escalateLogMessage,
|
||||||
logContext: { taskId, verdict, summary: summary?.slice(0, 100) },
|
logContext: { taskId, verdict, summary: summary?.slice(0, 100) },
|
||||||
arbiterInterventionCount: task.arbiter_intervention_count ?? 0,
|
arbiterInterventionCount: task.arbiter_intervention_count ?? 0,
|
||||||
|
patch: { reviewer_failure_count: 0 },
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -205,6 +263,7 @@ export function handleReviewerCompletion(args: {
|
|||||||
nextStatus: 'active',
|
nextStatus: 'active',
|
||||||
expectedUpdatedAt: task.updated_at,
|
expectedUpdatedAt: task.updated_at,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
|
patch: { reviewer_failure_count: 0 },
|
||||||
});
|
});
|
||||||
logger.info(
|
logger.info(
|
||||||
{ taskId, verdict },
|
{ taskId, verdict },
|
||||||
|
|||||||
@@ -143,6 +143,7 @@ function buildPairedTask(overrides: Partial<PairedTask> = {}): PairedTask {
|
|||||||
review_requested_at: null,
|
review_requested_at: null,
|
||||||
round_trip_count: 0,
|
round_trip_count: 0,
|
||||||
owner_failure_count: 0,
|
owner_failure_count: 0,
|
||||||
|
reviewer_failure_count: 0,
|
||||||
owner_step_done_streak: 0,
|
owner_step_done_streak: 0,
|
||||||
finalize_step_done_count: 0,
|
finalize_step_done_count: 0,
|
||||||
task_done_then_user_reopen_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(
|
vi.mocked(db.getPairedTaskById).mockReturnValue(
|
||||||
buildPairedTask({
|
buildPairedTask({
|
||||||
status: 'in_review',
|
status: 'in_review',
|
||||||
@@ -1416,11 +1417,38 @@ describe('paired execution context', () => {
|
|||||||
'task-1',
|
'task-1',
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
status: 'review_ready',
|
status: 'review_ready',
|
||||||
|
reviewer_failure_count: 1,
|
||||||
updated_at: expect.any(String),
|
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', () => {
|
it('keeps arbiter tasks arbiter_requested when arbiter execution fails without a terminal verdict', () => {
|
||||||
vi.mocked(db.getPairedTaskById).mockReturnValue(
|
vi.mocked(db.getPairedTaskById).mockReturnValue(
|
||||||
buildPairedTask({
|
buildPairedTask({
|
||||||
|
|||||||
@@ -141,6 +141,7 @@ function createActiveTaskForRoom(args: {
|
|||||||
review_requested_at: null,
|
review_requested_at: null,
|
||||||
round_trip_count: 0,
|
round_trip_count: 0,
|
||||||
owner_failure_count: 0,
|
owner_failure_count: 0,
|
||||||
|
reviewer_failure_count: 0,
|
||||||
owner_step_done_streak: 0,
|
owner_step_done_streak: 0,
|
||||||
finalize_step_done_count: 0,
|
finalize_step_done_count: 0,
|
||||||
task_done_then_user_reopen_count: 0,
|
task_done_then_user_reopen_count: 0,
|
||||||
@@ -493,6 +494,7 @@ export function preparePairedExecutionContext(args: {
|
|||||||
? {
|
? {
|
||||||
round_trip_count: 0,
|
round_trip_count: 0,
|
||||||
owner_failure_count: 0,
|
owner_failure_count: 0,
|
||||||
|
reviewer_failure_count: 0,
|
||||||
owner_step_done_streak: 0,
|
owner_step_done_streak: 0,
|
||||||
empty_step_done_streak: 0,
|
empty_step_done_streak: 0,
|
||||||
arbiter_intervention_count: 0,
|
arbiter_intervention_count: 0,
|
||||||
@@ -510,6 +512,7 @@ export function preparePairedExecutionContext(args: {
|
|||||||
? {
|
? {
|
||||||
round_trip_count: 0,
|
round_trip_count: 0,
|
||||||
owner_failure_count: 0,
|
owner_failure_count: 0,
|
||||||
|
reviewer_failure_count: 0,
|
||||||
owner_step_done_streak: 0,
|
owner_step_done_streak: 0,
|
||||||
empty_step_done_streak: 0,
|
empty_step_done_streak: 0,
|
||||||
arbiter_intervention_count: 0,
|
arbiter_intervention_count: 0,
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ export function transitionPairedTaskStatus(args: {
|
|||||||
review_requested_at?: string | null;
|
review_requested_at?: string | null;
|
||||||
round_trip_count?: number;
|
round_trip_count?: number;
|
||||||
owner_failure_count?: number;
|
owner_failure_count?: number;
|
||||||
|
reviewer_failure_count?: number;
|
||||||
owner_step_done_streak?: number;
|
owner_step_done_streak?: number;
|
||||||
finalize_step_done_count?: number;
|
finalize_step_done_count?: number;
|
||||||
task_done_then_user_reopen_count?: number;
|
task_done_then_user_reopen_count?: number;
|
||||||
@@ -111,6 +112,7 @@ export function applyPairedTaskPatch(args: {
|
|||||||
review_requested_at?: string | null;
|
review_requested_at?: string | null;
|
||||||
round_trip_count?: number;
|
round_trip_count?: number;
|
||||||
owner_failure_count?: number;
|
owner_failure_count?: number;
|
||||||
|
reviewer_failure_count?: number;
|
||||||
owner_step_done_streak?: number;
|
owner_step_done_streak?: number;
|
||||||
finalize_step_done_count?: number;
|
finalize_step_done_count?: number;
|
||||||
task_done_then_user_reopen_count?: number;
|
task_done_then_user_reopen_count?: number;
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { describe, expect, it } from 'vitest';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
findChannelForDeliveryRole,
|
findChannelForDeliveryRole,
|
||||||
|
neutralizeStrayBackticks,
|
||||||
|
neutralizeStrayMarkdown,
|
||||||
resolveChannelForDeliveryRole,
|
resolveChannelForDeliveryRole,
|
||||||
} from './router.js';
|
} from './router.js';
|
||||||
import { type Channel } from './types.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();
|
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);
|
let text = stripInternalTags(rawText);
|
||||||
if (!text) return '';
|
if (!text) return '';
|
||||||
text = stripToolCallLeaks(text);
|
text = stripToolCallLeaks(text);
|
||||||
@@ -86,6 +164,17 @@ export function formatOutbound(rawText: string): string {
|
|||||||
return redactSecrets(text);
|
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(
|
export function findChannel(
|
||||||
channels: Channel[],
|
channels: Channel[],
|
||||||
jid: string,
|
jid: string,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { getAgentOutputText } from './agent-output.js';
|
import { getAgentOutputText } from './agent-output.js';
|
||||||
import type { NewMessage } from './types.js';
|
import type { NewMessage } from './types.js';
|
||||||
import { logger } from './logger.js';
|
import { logger } from './logger.js';
|
||||||
import { formatOutbound } from './router.js';
|
import { sanitizeForOutbound } from './router.js';
|
||||||
import type { StructuredAgentOutput } from './types.js';
|
import type { StructuredAgentOutput } from './types.js';
|
||||||
|
|
||||||
const SESSION_COMMAND_CONTROL_PATTERNS = [
|
const SESSION_COMMAND_CONTROL_PATTERNS = [
|
||||||
@@ -86,7 +86,7 @@ function agentResultToText(result: AgentResult): string {
|
|||||||
result: result.result ?? null,
|
result: result.result ?? null,
|
||||||
output: result.output,
|
output: result.output,
|
||||||
});
|
});
|
||||||
return raw ? formatOutbound(raw) : '';
|
return raw ? sanitizeForOutbound(raw) : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -128,6 +128,7 @@ export interface PairedTask {
|
|||||||
review_requested_at: string | null;
|
review_requested_at: string | null;
|
||||||
round_trip_count: number;
|
round_trip_count: number;
|
||||||
owner_failure_count?: number | null;
|
owner_failure_count?: number | null;
|
||||||
|
reviewer_failure_count?: number | null;
|
||||||
owner_step_done_streak?: number | null;
|
owner_step_done_streak?: number | null;
|
||||||
finalize_step_done_count?: number | null;
|
finalize_step_done_count?: number | null;
|
||||||
task_done_then_user_reopen_count?: number | null;
|
task_done_then_user_reopen_count?: number | null;
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ import {
|
|||||||
mergeClaudeDashboardAccounts,
|
mergeClaudeDashboardAccounts,
|
||||||
type UsageRow,
|
type UsageRow,
|
||||||
} from './dashboard-usage-rows.js';
|
} 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 type { GroupQueue } from './group-queue.js';
|
||||||
import { logger } from './logger.js';
|
import { logger } from './logger.js';
|
||||||
import { isWatchCiTask } from './task-watch-status.js';
|
import { isWatchCiTask } from './task-watch-status.js';
|
||||||
@@ -208,12 +208,10 @@ async function refreshChannelMeta(
|
|||||||
if (!meta.name) continue;
|
if (!meta.name) continue;
|
||||||
const group = opts.roomBindings()[jid];
|
const group = opts.roomBindings()[jid];
|
||||||
if (!group || group.name === meta.name) continue;
|
if (!group || group.name === meta.name) continue;
|
||||||
logger.info(
|
logger.debug(
|
||||||
{ jid, oldName: group.name, newName: meta.name },
|
{ 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) {
|
} catch (err) {
|
||||||
logger.debug({ err }, 'Failed to refresh channel metadata');
|
logger.debug({ err }, 'Failed to refresh channel metadata');
|
||||||
|
|||||||
Reference in New Issue
Block a user