Files
EJClaw/src/db/base-schema.ts
Codex de54f13469 fix(paired): bound arbiter interventions to stop infinite arbiter loop
The owner↔reviewer↔arbiter loop could repeat forever: when the arbiter
ruled PROCEED/REVISE/RESET it reset round_trip_count to 0, and nothing
tracked how many times the arbiter had already intervened. A re-deadlock
re-invoked the arbiter without bound.

Add a persistent arbiter_intervention_count (new column + migration 020)
that survives the round-trip reset, and a configurable cap
ARBITER_MAX_INTERVENTIONS (default 1). Once the arbiter has intervened
that many times and the loop still deadlocks, requestArbiterOrEscalate
escalates straight to the user instead of re-invoking the arbiter.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-09 02:15:21 +09:00

461 lines
16 KiB
TypeScript

import { Database } from 'bun:sqlite';
const ROOM_SKILL_OVERRIDES_SCHEMA = `
CREATE TABLE IF NOT EXISTS room_skill_overrides (
chat_jid TEXT NOT NULL,
agent_type TEXT NOT NULL,
skill_scope TEXT NOT NULL,
skill_name TEXT NOT NULL,
enabled INTEGER NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (chat_jid, agent_type, skill_scope, skill_name),
FOREIGN KEY (chat_jid) REFERENCES room_settings(chat_jid) ON DELETE CASCADE,
CHECK (agent_type IN ('claude-code', 'codex')),
CHECK (enabled IN (0, 1)),
CHECK (length(skill_scope) > 0),
CHECK (length(skill_name) > 0)
);
CREATE INDEX IF NOT EXISTS idx_room_skill_overrides_room
ON room_skill_overrides(chat_jid, agent_type);
`;
export function applyBaseSchema(database: Database): void {
database.exec(`
CREATE TABLE IF NOT EXISTS chats (
jid TEXT PRIMARY KEY,
name TEXT,
last_message_time TEXT,
channel TEXT,
is_group INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS messages (
id TEXT,
chat_jid TEXT,
sender TEXT,
sender_name TEXT,
content TEXT,
timestamp TEXT,
seq INTEGER,
is_from_me INTEGER,
is_bot_message INTEGER DEFAULT 0,
message_source_kind TEXT NOT NULL DEFAULT 'human',
PRIMARY KEY (id, chat_jid),
FOREIGN KEY (chat_jid) REFERENCES chats(jid)
);
CREATE INDEX IF NOT EXISTS idx_timestamp ON messages(timestamp);
CREATE INDEX IF NOT EXISTS idx_messages_chat_timestamp ON messages(chat_jid, timestamp DESC);
CREATE TABLE IF NOT EXISTS message_sequence (
id INTEGER PRIMARY KEY AUTOINCREMENT
);
CREATE TABLE IF NOT EXISTS work_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
group_folder TEXT NOT NULL,
chat_jid TEXT NOT NULL,
agent_type TEXT NOT NULL,
service_id TEXT,
delivery_role TEXT,
status TEXT NOT NULL DEFAULT 'produced',
start_seq INTEGER,
end_seq INTEGER,
result_payload TEXT NOT NULL,
attachment_payload TEXT,
delivery_attempts INTEGER NOT NULL DEFAULT 0,
delivery_message_id TEXT,
last_error TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
delivered_at TEXT,
CHECK (status IN ('produced', 'delivery_retry', 'delivered')),
CHECK (delivery_role IN ('owner', 'reviewer', 'arbiter') OR delivery_role IS NULL)
);
CREATE INDEX IF NOT EXISTS idx_work_items_status ON work_items(status, updated_at);
CREATE INDEX IF NOT EXISTS idx_work_items_group_agent ON work_items(chat_jid, agent_type, delivery_role, status);
CREATE UNIQUE INDEX IF NOT EXISTS idx_work_items_open
ON work_items(chat_jid, agent_type, IFNULL(delivery_role, ''))
WHERE status IN ('produced', 'delivery_retry');
CREATE TABLE IF NOT EXISTS scheduled_tasks (
id TEXT PRIMARY KEY,
group_folder TEXT NOT NULL,
chat_jid TEXT NOT NULL,
agent_type TEXT,
ci_provider TEXT,
ci_metadata TEXT,
max_duration_ms INTEGER,
status_message_id TEXT,
status_started_at TEXT,
prompt TEXT NOT NULL,
schedule_type TEXT NOT NULL,
schedule_value TEXT NOT NULL,
next_run TEXT,
last_run TEXT,
last_result TEXT,
status TEXT DEFAULT 'active',
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_next_run ON scheduled_tasks(next_run);
CREATE INDEX IF NOT EXISTS idx_status ON scheduled_tasks(status);
CREATE TABLE IF NOT EXISTS task_run_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id TEXT NOT NULL,
run_at TEXT NOT NULL,
duration_ms INTEGER NOT NULL,
status TEXT NOT NULL,
result TEXT,
error TEXT,
FOREIGN KEY (task_id) REFERENCES scheduled_tasks(id)
);
CREATE INDEX IF NOT EXISTS idx_task_run_logs ON task_run_logs(task_id, run_at);
CREATE TABLE IF NOT EXISTS router_state (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS sessions (
group_folder TEXT NOT NULL,
agent_type TEXT NOT NULL DEFAULT 'claude-code',
session_id TEXT NOT NULL,
PRIMARY KEY (group_folder, agent_type)
);
CREATE TABLE IF NOT EXISTS paired_projects (
chat_jid TEXT PRIMARY KEY,
group_folder TEXT NOT NULL,
canonical_work_dir TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS paired_tasks (
id TEXT PRIMARY KEY,
chat_jid TEXT NOT NULL,
group_folder TEXT NOT NULL,
owner_service_id TEXT NOT NULL,
reviewer_service_id TEXT NOT NULL,
owner_agent_type TEXT,
reviewer_agent_type TEXT,
arbiter_agent_type TEXT,
title TEXT,
source_ref TEXT,
plan_notes TEXT,
review_requested_at TEXT,
round_trip_count INTEGER NOT NULL DEFAULT 0,
owner_failure_count INTEGER NOT NULL DEFAULT 0,
reviewer_failure_count INTEGER NOT NULL DEFAULT 0,
owner_step_done_streak INTEGER NOT NULL DEFAULT 0,
finalize_step_done_count INTEGER NOT NULL DEFAULT 0,
task_done_then_user_reopen_count INTEGER NOT NULL DEFAULT 0,
empty_step_done_streak INTEGER NOT NULL DEFAULT 0,
arbiter_intervention_count INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'active',
arbiter_verdict TEXT,
arbiter_requested_at TEXT,
completion_reason TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
CHECK (status IN ('active', 'review_ready', 'in_review', 'merge_ready', 'completed', 'arbiter_requested', 'in_arbitration')),
CHECK (owner_agent_type IN ('claude-code', 'codex') OR owner_agent_type IS NULL),
CHECK (reviewer_agent_type IN ('claude-code', 'codex') OR reviewer_agent_type IS NULL),
CHECK (arbiter_agent_type IN ('claude-code', 'codex') OR arbiter_agent_type IS NULL)
);
CREATE INDEX IF NOT EXISTS idx_paired_tasks_chat_status
ON paired_tasks(chat_jid, status, updated_at);
CREATE TABLE IF NOT EXISTS paired_workspaces (
id TEXT PRIMARY KEY,
task_id TEXT NOT NULL,
role TEXT NOT NULL,
workspace_dir TEXT NOT NULL,
snapshot_source_dir TEXT,
snapshot_ref TEXT,
status TEXT NOT NULL DEFAULT 'ready',
snapshot_refreshed_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
CHECK (role IN ('owner', 'reviewer')),
CHECK (status IN ('ready', 'stale'))
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_paired_workspaces_task_role
ON paired_workspaces(task_id, role);
CREATE TABLE IF NOT EXISTS paired_turn_outputs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id TEXT NOT NULL,
turn_number INTEGER NOT NULL,
role TEXT NOT NULL,
output_text TEXT NOT NULL, attachment_payload TEXT,
verdict TEXT,
created_at TEXT NOT NULL,
UNIQUE(task_id, turn_number, role)
);
CREATE INDEX IF NOT EXISTS idx_paired_turn_outputs_task
ON paired_turn_outputs(task_id, turn_number);
CREATE TABLE IF NOT EXISTS paired_turn_reservations (
chat_jid TEXT NOT NULL,
task_id TEXT NOT NULL,
task_status TEXT NOT NULL,
round_trip_count INTEGER NOT NULL DEFAULT 0,
task_updated_at TEXT NOT NULL,
turn_id TEXT NOT NULL,
turn_attempt_id TEXT,
turn_attempt_no INTEGER,
turn_role TEXT NOT NULL,
intent_kind TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
scheduled_run_id TEXT,
consumed_run_id TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
consumed_at TEXT,
PRIMARY KEY (chat_jid, task_id, task_updated_at, intent_kind),
FOREIGN KEY (turn_id, turn_attempt_no)
REFERENCES paired_turn_attempts(turn_id, attempt_no)
ON DELETE CASCADE,
CHECK (status IN ('pending', 'completed')),
CHECK (turn_role IN ('owner', 'reviewer', 'arbiter')),
CHECK (
intent_kind IN (
'owner-turn',
'reviewer-turn',
'arbiter-turn',
'owner-follow-up',
'finalize-owner-turn'
)
)
);
CREATE INDEX IF NOT EXISTS idx_paired_turn_reservations_task
ON paired_turn_reservations(task_id, task_updated_at, status);
CREATE TABLE IF NOT EXISTS paired_turns (
turn_id TEXT PRIMARY KEY,
task_id TEXT NOT NULL,
task_updated_at TEXT NOT NULL,
role TEXT NOT NULL,
intent_kind TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
CHECK (role IN ('owner', 'reviewer', 'arbiter')),
CHECK (
intent_kind IN (
'owner-turn',
'reviewer-turn',
'arbiter-turn',
'owner-follow-up',
'finalize-owner-turn'
)
)
);
CREATE INDEX IF NOT EXISTS idx_paired_turns_task
ON paired_turns(task_id, task_updated_at, updated_at);
CREATE TABLE IF NOT EXISTS paired_turn_attempts (
attempt_id TEXT NOT NULL PRIMARY KEY,
parent_attempt_id TEXT,
parent_handoff_id INTEGER,
continuation_handoff_id INTEGER,
turn_id TEXT NOT NULL,
attempt_no INTEGER NOT NULL,
task_id TEXT NOT NULL,
task_updated_at TEXT NOT NULL,
role TEXT NOT NULL,
intent_kind TEXT NOT NULL,
state TEXT NOT NULL,
executor_service_id TEXT,
executor_agent_type TEXT,
active_run_id TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
completed_at TEXT,
last_error TEXT,
UNIQUE (turn_id, attempt_no),
FOREIGN KEY (parent_attempt_id)
REFERENCES paired_turn_attempts(attempt_id)
ON DELETE CASCADE,
FOREIGN KEY (parent_handoff_id)
REFERENCES service_handoffs(id)
ON DELETE SET NULL,
FOREIGN KEY (continuation_handoff_id)
REFERENCES service_handoffs(id)
ON DELETE SET NULL,
FOREIGN KEY (turn_id) REFERENCES paired_turns(turn_id) ON DELETE CASCADE,
CHECK (role IN ('owner', 'reviewer', 'arbiter')),
CHECK (
intent_kind IN (
'owner-turn',
'reviewer-turn',
'arbiter-turn',
'owner-follow-up',
'finalize-owner-turn'
)
),
CHECK (
state IN (
'running',
'delegated',
'completed',
'failed',
'cancelled'
)
),
CHECK (executor_agent_type IN ('claude-code', 'codex') OR executor_agent_type IS NULL)
);
CREATE INDEX IF NOT EXISTS idx_paired_turn_attempts_turn
ON paired_turn_attempts(turn_id, attempt_no);
CREATE INDEX IF NOT EXISTS idx_paired_turn_attempts_task
ON paired_turn_attempts(task_id, task_updated_at, attempt_no);
CREATE TABLE IF NOT EXISTS paired_task_execution_leases (
task_id TEXT PRIMARY KEY,
chat_jid TEXT NOT NULL,
role TEXT NOT NULL,
turn_id TEXT NOT NULL,
turn_attempt_id TEXT,
turn_attempt_no INTEGER,
intent_kind TEXT NOT NULL,
claimed_run_id TEXT NOT NULL,
claimed_service_id TEXT NOT NULL,
task_status TEXT NOT NULL,
task_updated_at TEXT NOT NULL,
claimed_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
FOREIGN KEY (turn_id, turn_attempt_no)
REFERENCES paired_turn_attempts(turn_id, attempt_no)
ON DELETE CASCADE,
CHECK (role IN ('owner', 'reviewer', 'arbiter')),
CHECK (
intent_kind IN (
'owner-turn',
'reviewer-turn',
'arbiter-turn',
'owner-follow-up',
'finalize-owner-turn'
)
)
);
CREATE INDEX IF NOT EXISTS idx_paired_task_execution_leases_expires_at
ON paired_task_execution_leases(expires_at);
CREATE TABLE IF NOT EXISTS channel_owner (
chat_jid TEXT PRIMARY KEY,
owner_service_id TEXT NOT NULL,
reviewer_service_id TEXT,
arbiter_service_id TEXT,
owner_agent_type TEXT,
reviewer_agent_type TEXT,
arbiter_agent_type TEXT,
activated_at TEXT,
reason TEXT,
CHECK (owner_agent_type IN ('claude-code', 'codex') OR owner_agent_type IS NULL),
CHECK (reviewer_agent_type IN ('claude-code', 'codex') OR reviewer_agent_type IS NULL),
CHECK (arbiter_agent_type IN ('claude-code', 'codex') OR arbiter_agent_type IS NULL)
);
CREATE TABLE IF NOT EXISTS room_settings (
chat_jid TEXT PRIMARY KEY,
room_mode TEXT NOT NULL,
mode_source TEXT NOT NULL DEFAULT 'explicit',
name TEXT,
folder TEXT,
trigger_pattern TEXT,
requires_trigger INTEGER DEFAULT 1,
is_main INTEGER DEFAULT 0,
owner_agent_type TEXT,
work_dir TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL,
CHECK (room_mode IN ('single', 'tribunal')),
CHECK (owner_agent_type IN ('claude-code', 'codex') OR owner_agent_type IS NULL)
);
CREATE TABLE IF NOT EXISTS room_role_overrides (
chat_jid TEXT NOT NULL,
role TEXT NOT NULL,
agent_type TEXT NOT NULL,
agent_config_json TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (chat_jid, role),
CHECK (role IN ('owner', 'reviewer', 'arbiter')),
CHECK (agent_type IN ('claude-code', 'codex'))
);
CREATE TABLE IF NOT EXISTS service_handoffs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
chat_jid TEXT NOT NULL,
group_folder TEXT NOT NULL,
source_service_id TEXT NOT NULL,
target_service_id TEXT NOT NULL,
paired_task_id TEXT,
paired_task_updated_at TEXT,
turn_id TEXT,
turn_attempt_id TEXT,
turn_attempt_no INTEGER,
turn_intent_kind TEXT,
turn_role TEXT,
source_role TEXT,
source_agent_type TEXT,
target_role TEXT,
target_agent_type TEXT NOT NULL,
prompt TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
start_seq INTEGER,
end_seq INTEGER,
reason TEXT,
intended_role TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
claimed_at TEXT,
completed_at TEXT,
last_error TEXT,
FOREIGN KEY (turn_id, turn_attempt_no)
REFERENCES paired_turn_attempts(turn_id, attempt_no)
ON DELETE CASCADE,
CHECK (status IN ('pending', 'claimed', 'completed', 'failed')),
CHECK (intended_role IN ('owner', 'reviewer', 'arbiter') OR intended_role IS NULL),
CHECK (
turn_intent_kind IN (
'owner-turn',
'reviewer-turn',
'arbiter-turn',
'owner-follow-up',
'finalize-owner-turn'
) OR turn_intent_kind IS NULL
),
CHECK (turn_role IN ('owner', 'reviewer', 'arbiter') OR turn_role IS NULL),
CHECK (source_role IN ('owner', 'reviewer', 'arbiter') OR source_role IS NULL),
CHECK (target_role IN ('owner', 'reviewer', 'arbiter') OR target_role IS NULL)
);
CREATE INDEX IF NOT EXISTS idx_service_handoffs_target
ON service_handoffs(status, target_role, target_agent_type, created_at);
CREATE TABLE IF NOT EXISTS memories (
id INTEGER PRIMARY KEY,
scope_kind TEXT NOT NULL,
scope_key TEXT NOT NULL,
content TEXT NOT NULL,
keywords_json TEXT NOT NULL DEFAULT '[]',
memory_kind TEXT,
source_kind TEXT NOT NULL,
source_ref TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_used_at TEXT,
archived_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_memories_scope ON memories(scope_kind, scope_key);
CREATE INDEX IF NOT EXISTS idx_memories_active
ON memories(scope_kind, scope_key, archived_at, created_at);
CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(
content,
keywords,
content='',
tokenize='unicode61'
);
CREATE TRIGGER IF NOT EXISTS memories_ai AFTER INSERT ON memories BEGIN
INSERT INTO memories_fts(rowid, content, keywords)
VALUES (new.id, new.content, new.keywords_json);
END;
CREATE TRIGGER IF NOT EXISTS memories_ad AFTER DELETE ON memories BEGIN
INSERT INTO memories_fts(memories_fts, rowid, content, keywords)
VALUES ('delete', old.id, old.content, old.keywords_json);
END;
CREATE TRIGGER IF NOT EXISTS memories_au AFTER UPDATE ON memories BEGIN
INSERT INTO memories_fts(memories_fts, rowid, content, keywords)
VALUES ('delete', old.id, old.content, old.keywords_json);
INSERT INTO memories_fts(rowid, content, keywords)
VALUES (new.id, new.content, new.keywords_json);
END;
`);
database.exec(ROOM_SKILL_OVERRIDES_SCHEMA);
}