diff --git a/src/db.test.ts b/src/db.test.ts index 1f2dff7..795fdae 100644 --- a/src/db.test.ts +++ b/src/db.test.ts @@ -59,6 +59,7 @@ import { rememberMemory, storeChatMetadata, storeMessage, + setChannelOwnerLease, updateRegisteredGroupName, updatePairedTask, upsertPairedProject, @@ -70,6 +71,7 @@ import { CLAUDE_SERVICE_ID, CODEX_MAIN_SERVICE_ID, CODEX_REVIEW_SERVICE_ID, + SERVICE_SESSION_SCOPE, } from './config.js'; import { resolveTaskRuntimeIpcPath, @@ -425,17 +427,15 @@ describe('session accessors', () => { migratedDb.close(); }); - it('backfills legacy service-scoped sessions into canonical agent sessions during init', () => { - const tempDir = fs.mkdtempSync('/tmp/ejclaw-session-backfill-'); + it('backfills legacy service_sessions rows into sessions during init', () => { + const tempDir = fs.mkdtempSync('/tmp/ejclaw-service-sessions-backfill-'); const dbPath = path.join(tempDir, 'messages.db'); const legacyDb = new Database(dbPath); legacyDb.exec(` CREATE TABLE 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) + group_folder TEXT PRIMARY KEY, + session_id TEXT NOT NULL ); CREATE TABLE service_sessions ( group_folder TEXT NOT NULL, @@ -449,25 +449,27 @@ describe('session accessors', () => { `INSERT INTO service_sessions (group_folder, service_id, session_id) VALUES (?, ?, ?)`, ) - .run('group-legacy', CLAUDE_SERVICE_ID, 'legacy-session-123'); + .run('group-legacy', CLAUDE_SERVICE_ID, 'legacy-service-session-123'); legacyDb.close(); _initTestDatabaseFromFile(dbPath); expect(getSession('group-legacy', 'claude-code')).toBe( - 'legacy-session-123', + 'legacy-service-session-123', ); const migratedDb = new Database(dbPath, { readonly: true }); - expect( + const hasServiceSessions = Boolean( migratedDb .prepare( - `SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'service_sessions'`, + `SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'service_sessions'`, ) .get(), - ).toBeUndefined(); + ); + expect(hasServiceSessions).toBe(false); migratedDb.close(); }); + }); // --- storeChatMetadata --- @@ -905,8 +907,94 @@ describe('paired task state', () => { }); }); - it('backfills paired task role metadata from stable room metadata for pre-column legacy rows', () => { - const tempDir = fs.mkdtempSync('/tmp/ejclaw-paired-task-legacy-shadow-'); + it('preserves raw legacy paired task service ids during init when failover created the task', () => { + const tempDir = fs.mkdtempSync('/tmp/ejclaw-paired-task-legacy-failover-'); + const dbPath = path.join(tempDir, 'messages.db'); + const legacyDb = new Database(dbPath); + + legacyDb.exec(` + CREATE TABLE 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, + status TEXT NOT NULL, + arbiter_verdict TEXT, + arbiter_requested_at TEXT, + completion_reason TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + `); + + legacyDb + .prepare( + `INSERT INTO paired_tasks ( + id, + chat_jid, + group_folder, + owner_service_id, + reviewer_service_id, + owner_agent_type, + reviewer_agent_type, + arbiter_agent_type, + title, + source_ref, + plan_notes, + review_requested_at, + round_trip_count, + status, + arbiter_verdict, + arbiter_requested_at, + completion_reason, + created_at, + updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + 'paired-legacy-failover', + 'dc:paired-failover', + 'paired-failover-room', + CODEX_REVIEW_SERVICE_ID, + CLAUDE_SERVICE_ID, + null, + null, + null, + 'legacy failover task', + null, + null, + null, + 0, + 'active', + null, + null, + null, + '2026-03-28T00:00:00.000Z', + '2026-03-28T00:00:00.000Z', + ); + legacyDb.close(); + + _initTestDatabaseFromFile(dbPath); + + expect(getPairedTaskById('paired-legacy-failover')).toMatchObject({ + owner_service_id: CODEX_REVIEW_SERVICE_ID, + reviewer_service_id: CLAUDE_SERVICE_ID, + owner_agent_type: 'codex', + reviewer_agent_type: 'claude-code', + }); + }); + + it('preserves raw legacy paired task service ids during init when registered group metadata is present', () => { + const tempDir = fs.mkdtempSync('/tmp/ejclaw-paired-task-legacy-groups-'); const dbPath = path.join(tempDir, 'messages.db'); const legacyDb = new Database(dbPath); @@ -931,18 +1019,20 @@ describe('paired task state', () => { 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, - status TEXT NOT NULL DEFAULT 'active', + status TEXT NOT NULL, 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')) + updated_at TEXT NOT NULL ); `); @@ -961,17 +1051,17 @@ describe('paired task state', () => { ) VALUES (?, ?, ?, ?, ?, NULL, 1, 0, ?, NULL)`, ); insertGroup.run( - 'dc:legacy-failover', - 'Legacy Failover Room', - 'legacy-failover-room', + 'dc:paired-failover-groups', + 'Legacy Failover Groups', + 'paired-failover-groups', '@Claude', '2024-01-01T00:00:00.000Z', 'claude-code', ); insertGroup.run( - 'dc:legacy-failover', - 'Legacy Failover Room', - 'legacy-failover-room', + 'dc:paired-failover-groups', + 'Legacy Failover Groups', + 'paired-failover-groups', '@Codex', '2024-01-01T00:00:00.000Z', 'codex', @@ -985,6 +1075,9 @@ describe('paired task state', () => { group_folder, owner_service_id, reviewer_service_id, + owner_agent_type, + reviewer_agent_type, + arbiter_agent_type, title, source_ref, plan_notes, @@ -996,16 +1089,19 @@ describe('paired task state', () => { completion_reason, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) .run( - 'paired-legacy-failover', - 'dc:legacy-failover', - 'legacy-failover-room', + 'paired-legacy-groups', + 'dc:paired-failover-groups', + 'paired-failover-groups', CODEX_REVIEW_SERVICE_ID, CLAUDE_SERVICE_ID, null, - 'HEAD', + null, + null, + 'legacy failover with groups', + null, null, null, 0, @@ -1020,15 +1116,125 @@ describe('paired task state', () => { _initTestDatabaseFromFile(dbPath); - expect(getStoredRoomSettings('dc:legacy-failover')).toMatchObject({ - ownerAgentType: 'codex', - }); - expect(getPairedTaskById('paired-legacy-failover')).toMatchObject({ - owner_service_id: CODEX_MAIN_SERVICE_ID, + expect(getPairedTaskById('paired-legacy-groups')).toMatchObject({ + owner_service_id: CODEX_REVIEW_SERVICE_ID, reviewer_service_id: CLAUDE_SERVICE_ID, owner_agent_type: 'codex', reviewer_agent_type: 'claude-code', - arbiter_agent_type: ARBITER_AGENT_TYPE ?? null, + }); + }); + + it('preserves raw legacy channel owner lease service ids during init', () => { + const tempDir = fs.mkdtempSync('/tmp/ejclaw-channel-owner-legacy-'); + const dbPath = path.join(tempDir, 'messages.db'); + const legacyDb = new Database(dbPath); + + legacyDb.exec(` + CREATE TABLE 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 + ); + `); + + legacyDb + .prepare( + `INSERT INTO channel_owner ( + chat_jid, + owner_service_id, + reviewer_service_id, + arbiter_service_id, + owner_agent_type, + reviewer_agent_type, + arbiter_agent_type, + activated_at, + reason + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + 'dc:legacy-channel-owner', + CODEX_REVIEW_SERVICE_ID, + CLAUDE_SERVICE_ID, + null, + null, + null, + null, + '2026-03-28T00:00:00.000Z', + 'legacy-failover', + ); + legacyDb.close(); + + _initTestDatabaseFromFile(dbPath); + + expect(getChannelOwnerLease('dc:legacy-channel-owner')).toMatchObject({ + owner_service_id: CODEX_REVIEW_SERVICE_ID, + reviewer_service_id: CLAUDE_SERVICE_ID, + owner_agent_type: 'codex', + reviewer_agent_type: 'claude-code', + }); + }); + + it('preserves stored reviewer service ids during init even when reviewer agent metadata exists', () => { + const tempDir = fs.mkdtempSync('/tmp/ejclaw-channel-owner-stored-reviewer-'); + const dbPath = path.join(tempDir, 'messages.db'); + const legacyDb = new Database(dbPath); + + legacyDb.exec(` + CREATE TABLE 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 + ); + `); + + legacyDb + .prepare( + `INSERT INTO channel_owner ( + chat_jid, + owner_service_id, + reviewer_service_id, + arbiter_service_id, + owner_agent_type, + reviewer_agent_type, + arbiter_agent_type, + activated_at, + reason + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + 'dc:legacy-channel-owner-stored-reviewer', + CLAUDE_SERVICE_ID, + 'stale-reviewer-shadow', + null, + 'claude-code', + 'codex', + null, + '2026-03-28T00:00:00.000Z', + 'legacy-reviewer-stored-id', + ); + legacyDb.close(); + + _initTestDatabaseFromFile(dbPath); + + expect( + getChannelOwnerLease('dc:legacy-channel-owner-stored-reviewer'), + ).toMatchObject({ + owner_service_id: CLAUDE_SERVICE_ID, + reviewer_service_id: 'stale-reviewer-shadow', + owner_agent_type: 'claude-code', + reviewer_agent_type: 'codex', }); }); @@ -1326,8 +1532,8 @@ describe('paired task state', () => { trigger: '@Custom', }); expect(getPairedTaskById('paired-explicit-owner')).toMatchObject({ - owner_service_id: CLAUDE_SERVICE_ID, - reviewer_service_id: CLAUDE_SERVICE_ID, + owner_service_id: CODEX_REVIEW_SERVICE_ID, + reviewer_service_id: CODEX_MAIN_SERVICE_ID, owner_agent_type: 'claude-code', reviewer_agent_type: 'claude-code', arbiter_agent_type: ARBITER_AGENT_TYPE ?? null, @@ -1701,6 +1907,133 @@ describe('room assignment writes', () => { }); }); + it('materializes inferred room_settings from legacy registered_groups rows during init', () => { + const tempDir = fs.mkdtempSync('/tmp/ejclaw-legacy-room-settings-init-'); + const dbPath = path.join(tempDir, 'messages.db'); + const legacyDb = new Database(dbPath); + + legacyDb.exec(` + CREATE TABLE registered_groups ( + jid TEXT NOT NULL, + name TEXT NOT NULL, + folder TEXT NOT NULL, + trigger_pattern TEXT NOT NULL, + added_at TEXT NOT NULL, + agent_config TEXT, + requires_trigger INTEGER DEFAULT 1, + is_main INTEGER DEFAULT 0, + agent_type TEXT NOT NULL DEFAULT 'claude-code', + work_dir TEXT, + PRIMARY KEY (jid, agent_type), + UNIQUE (folder, agent_type) + ); + `); + + const insertGroup = legacyDb.prepare( + `INSERT INTO registered_groups ( + jid, + name, + folder, + trigger_pattern, + added_at, + agent_config, + requires_trigger, + is_main, + agent_type, + work_dir + ) VALUES (?, ?, ?, ?, ?, NULL, 1, 0, ?, NULL)`, + ); + insertGroup.run( + 'dc:legacy-sql', + 'Legacy SQL Room', + 'legacy-sql-room', + '@Claude', + '2024-01-01T00:00:00.000Z', + 'claude-code', + ); + insertGroup.run( + 'dc:legacy-sql', + 'Legacy SQL Room', + 'legacy-sql-room', + '@Codex', + '2024-01-01T00:00:00.000Z', + 'codex', + ); + legacyDb.close(); + + _initTestDatabaseFromFile(dbPath); + + expect(getStoredRoomSettings('dc:legacy-sql')).toMatchObject({ + chatJid: 'dc:legacy-sql', + roomMode: 'tribunal', + modeSource: 'inferred', + name: 'Legacy SQL Room', + folder: 'legacy-sql-room', + ownerAgentType: 'codex', + }); + expect(getEffectiveRoomMode('dc:legacy-sql')).toBe('tribunal'); + expect(getEffectiveRuntimeRoomMode('dc:legacy-sql')).toBe('tribunal'); + }); + + it('fails init when legacy registered_groups rows conflict on room-level metadata', () => { + const tempDir = fs.mkdtempSync('/tmp/ejclaw-legacy-room-conflict-init-'); + const dbPath = path.join(tempDir, 'messages.db'); + const legacyDb = new Database(dbPath); + + legacyDb.exec(` + CREATE TABLE registered_groups ( + jid TEXT NOT NULL, + name TEXT NOT NULL, + folder TEXT NOT NULL, + trigger_pattern TEXT NOT NULL, + added_at TEXT NOT NULL, + agent_config TEXT, + requires_trigger INTEGER DEFAULT 1, + is_main INTEGER DEFAULT 0, + agent_type TEXT NOT NULL DEFAULT 'claude-code', + work_dir TEXT, + PRIMARY KEY (jid, agent_type), + UNIQUE (folder, agent_type) + ); + `); + + const insertGroup = legacyDb.prepare( + `INSERT INTO registered_groups ( + jid, + name, + folder, + trigger_pattern, + added_at, + agent_config, + requires_trigger, + is_main, + agent_type, + work_dir + ) VALUES (?, ?, ?, ?, ?, NULL, 1, 0, ?, NULL)`, + ); + insertGroup.run( + 'dc:legacy-conflict', + 'Legacy Conflict Room', + 'legacy-conflict-a', + '@Claude', + '2024-01-01T00:00:00.000Z', + 'claude-code', + ); + insertGroup.run( + 'dc:legacy-conflict', + 'Legacy Conflict Room', + 'legacy-conflict-b', + '@Codex', + '2024-01-01T00:00:00.000Z', + 'codex', + ); + legacyDb.close(); + + expect(() => _initTestDatabaseFromFile(dbPath)).toThrow( + /Conflicting room-level registered_groups metadata/, + ); + }); + it('ignores stale registered_groups capability rows once room_settings exists', () => { const tempDir = fs.mkdtempSync('/tmp/ejclaw-room-ssot-'); const dbPath = path.join(tempDir, 'messages.db'); @@ -2044,85 +2377,6 @@ describe('paired room registration', () => { expect(getEffectiveRuntimeRoomMode('dc:legacy-paired')).toBe('tribunal'); }); - it('backfills inferred room modes for legacy SQL rows missing room_settings', () => { - const tempDir = fs.mkdtempSync('/tmp/ejclaw-room-mode-'); - const dbPath = path.join(tempDir, 'messages.db'); - const legacyDb = new Database(dbPath); - - legacyDb.exec(` - CREATE TABLE registered_groups ( - jid TEXT NOT NULL, - name TEXT NOT NULL, - folder TEXT NOT NULL, - trigger_pattern TEXT NOT NULL, - added_at TEXT NOT NULL, - agent_config TEXT, - requires_trigger INTEGER DEFAULT 1, - is_main INTEGER DEFAULT 0, - agent_type TEXT NOT NULL DEFAULT 'claude-code', - work_dir TEXT, - PRIMARY KEY (jid, agent_type), - UNIQUE (folder, agent_type) - ); - CREATE TABLE room_settings ( - chat_jid TEXT PRIMARY KEY, - room_mode TEXT NOT NULL, - updated_at TEXT NOT NULL, - CHECK (room_mode IN ('single', 'tribunal')) - ); - `); - - const insertGroup = legacyDb.prepare( - `INSERT INTO registered_groups ( - jid, - name, - folder, - trigger_pattern, - added_at, - agent_config, - requires_trigger, - is_main, - agent_type, - work_dir - ) VALUES (?, ?, ?, ?, ?, NULL, 1, 0, ?, NULL)`, - ); - insertGroup.run( - 'dc:legacy-sql', - 'Legacy SQL Room', - 'legacy-sql-room', - '@Claude', - '2024-01-01T00:00:00.000Z', - 'claude-code', - ); - insertGroup.run( - 'dc:legacy-sql', - 'Legacy SQL Room', - 'legacy-sql-room', - '@Codex', - '2024-01-01T00:00:00.000Z', - 'codex', - ); - legacyDb.close(); - - _initTestDatabaseFromFile(dbPath); - - expect(getExplicitRoomMode('dc:legacy-sql')).toBeUndefined(); - expect(getEffectiveRoomMode('dc:legacy-sql')).toBe('tribunal'); - expect(getEffectiveRuntimeRoomMode('dc:legacy-sql')).toBe('tribunal'); - - expect(getStoredRoomSettings('dc:legacy-sql')).toMatchObject({ - chatJid: 'dc:legacy-sql', - roomMode: 'tribunal', - modeSource: 'inferred', - name: 'Legacy SQL Room', - folder: 'legacy-sql-room', - trigger: '@Codex', - requiresTrigger: true, - isMain: false, - ownerAgentType: 'codex', - }); - }); - it('keeps room-level metadata synced on setRegisteredGroup helper writes', () => { _setRegisteredGroupForTests('dc:room-settings', { name: 'Room Settings Test', @@ -2174,71 +2428,6 @@ describe('paired room registration', () => { }); }); - it('fails room_settings backfill when room-level metadata conflicts across agent rows', () => { - const tempDir = fs.mkdtempSync('/tmp/ejclaw-room-settings-conflict-'); - const dbPath = path.join(tempDir, 'messages.db'); - const legacyDb = new Database(dbPath); - - legacyDb.exec(` - CREATE TABLE registered_groups ( - jid TEXT NOT NULL, - name TEXT NOT NULL, - folder TEXT NOT NULL, - trigger_pattern TEXT NOT NULL, - added_at TEXT NOT NULL, - agent_config TEXT, - requires_trigger INTEGER DEFAULT 1, - is_main INTEGER DEFAULT 0, - agent_type TEXT NOT NULL DEFAULT 'claude-code', - work_dir TEXT, - PRIMARY KEY (jid, agent_type), - UNIQUE (folder, agent_type) - ); - CREATE TABLE room_settings ( - chat_jid TEXT PRIMARY KEY, - room_mode TEXT NOT NULL, - updated_at TEXT NOT NULL, - CHECK (room_mode IN ('single', 'tribunal')) - ); - `); - - const insertGroup = legacyDb.prepare( - `INSERT INTO registered_groups ( - jid, - name, - folder, - trigger_pattern, - added_at, - agent_config, - requires_trigger, - is_main, - agent_type, - work_dir - ) VALUES (?, ?, ?, ?, ?, NULL, 1, 0, ?, NULL)`, - ); - insertGroup.run( - 'dc:conflict', - 'Conflict Room', - 'conflict-folder-1', - '@Claude', - '2024-01-01T00:00:00.000Z', - 'claude-code', - ); - insertGroup.run( - 'dc:conflict', - 'Conflict Room', - 'conflict-folder-2', - '@Codex', - '2024-01-01T00:00:00.000Z', - 'codex', - ); - legacyDb.close(); - - expect(() => _initTestDatabaseFromFile(dbPath)).toThrow( - /Conflicting room-level registered_groups metadata/, - ); - }); - it('lets explicit single override dual registration for paired-room checks', () => { _setRegisteredGroupForTests('dc:explicit-single', { name: 'Explicit Single', @@ -2478,7 +2667,7 @@ describe('service handoff completion', () => { }); }); - it('stores owner handoff service ids as stable role-slot shadows', () => { + it('derives owner handoff service ids as stable role-slot shadows when raw ids are omitted', () => { assignRoom('dc:handoff-owner-shadow', { name: 'Owner Handoff Shadow', roomMode: 'tribunal', @@ -2491,10 +2680,9 @@ describe('service handoff completion', () => { const handoff = createServiceHandoff({ chat_jid: 'dc:handoff-owner-shadow', group_folder: 'owner-handoff-shadow', - source_service_id: CLAUDE_SERVICE_ID, - target_service_id: CODEX_REVIEW_SERVICE_ID, source_role: 'owner', target_role: 'owner', + source_agent_type: 'codex', target_agent_type: 'codex', prompt: 'owner fallback', reason: 'claude-usage-exhausted', @@ -2514,7 +2702,7 @@ describe('service handoff completion', () => { ]); }); - it('normalizes legacy owner handoff service ids to stable role-slot shadows during init', () => { + it('preserves stored owner handoff service ids during init when service id columns already exist', () => { const tempDir = fs.mkdtempSync('/tmp/ejclaw-handoff-shadow-'); const dbPath = path.join(tempDir, 'messages.db'); const legacyDb = new Database(dbPath); @@ -2634,255 +2822,43 @@ describe('service handoff completion', () => { _initTestDatabaseFromFile(dbPath); - expect(getPendingServiceHandoffs(CODEX_MAIN_SERVICE_ID)).toEqual([ + expect(getPendingServiceHandoffs(CODEX_REVIEW_SERVICE_ID)).toEqual([ expect.objectContaining({ chat_jid: 'dc:handoff-owner-shadow', - source_service_id: CODEX_MAIN_SERVICE_ID, - target_service_id: CODEX_MAIN_SERVICE_ID, + source_service_id: CLAUDE_SERVICE_ID, + target_service_id: CODEX_REVIEW_SERVICE_ID, source_role: 'owner', target_role: 'owner', }), ]); }); - it('rebuilds legacy service-shadow tables into canonical schemas while preserving derived runtime shadows', () => { - const tempDir = fs.mkdtempSync('/tmp/ejclaw-canonical-schema-'); - const dbPath = path.join(tempDir, 'messages.db'); - const legacyDb = new Database(dbPath); - - legacyDb.exec(` - CREATE TABLE 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 NOT NULL DEFAULT '', - delivery_role TEXT, - status TEXT NOT NULL DEFAULT 'produced', - start_seq INTEGER, - end_seq INTEGER, - result_payload TEXT NOT NULL, - delivery_attempts INTEGER NOT NULL DEFAULT 0, - delivery_message_id TEXT, - last_error TEXT, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - delivered_at TEXT - ); - CREATE TABLE 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 - ); - CREATE TABLE 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, - 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 - ); - CREATE TABLE 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, - source_role 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, - claimed_at TEXT, - completed_at TEXT, - last_error TEXT - ); - `); - - legacyDb - .prepare( - `INSERT INTO work_items ( - group_folder, chat_jid, agent_type, service_id, delivery_role, status, - start_seq, end_seq, result_payload, delivery_attempts, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ) - .run( - 'canonical-schema', - 'dc:canonical-schema', - 'codex', - CODEX_REVIEW_SERVICE_ID, - 'reviewer', - 'produced', - 1, - 2, - 'payload', - 0, - '2026-04-03T00:00:00.000Z', - '2026-04-03T00:00:00.000Z', - ); - - legacyDb - .prepare( - `INSERT INTO channel_owner ( - chat_jid, owner_service_id, reviewer_service_id, arbiter_service_id, - owner_agent_type, reviewer_agent_type, arbiter_agent_type, activated_at, reason - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ) - .run( - 'dc:canonical-schema', - CODEX_MAIN_SERVICE_ID, - CLAUDE_SERVICE_ID, - null, - 'codex', - 'claude-code', - null, - '2026-04-03T00:00:00.000Z', - 'explicit', - ); - - legacyDb - .prepare( - `INSERT INTO paired_tasks ( - id, chat_jid, group_folder, owner_service_id, reviewer_service_id, - owner_agent_type, reviewer_agent_type, arbiter_agent_type, title, - source_ref, plan_notes, review_requested_at, round_trip_count, - status, arbiter_verdict, arbiter_requested_at, completion_reason, - created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ) - .run( - 'task-canonical-schema', - 'dc:canonical-schema', - 'canonical-schema', - CODEX_MAIN_SERVICE_ID, - CLAUDE_SERVICE_ID, - 'codex', - 'claude-code', - null, - 'canonical task', - null, - null, - null, - 0, - 'active', - null, - null, - null, - '2026-04-03T00:00:00.000Z', - '2026-04-03T00:00:00.000Z', - ); - - legacyDb - .prepare( - `INSERT INTO service_handoffs ( - chat_jid, group_folder, source_service_id, target_service_id, - source_role, target_role, target_agent_type, prompt, status, - reason, intended_role, created_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ) - .run( - 'dc:canonical-schema', - 'canonical-schema', - CODEX_MAIN_SERVICE_ID, - CLAUDE_SERVICE_ID, - 'owner', - 'reviewer', - 'claude-code', - 'review please', - 'pending', - 'reviewer-codex-manual', - 'reviewer', - '2026-04-03T00:00:00.000Z', - ); - legacyDb.close(); - - _initTestDatabaseFromFile(dbPath); - - expect( - getOpenWorkItem('dc:canonical-schema', 'codex', CODEX_REVIEW_SERVICE_ID), - ).toMatchObject({ - delivery_role: 'reviewer', - service_id: CODEX_REVIEW_SERVICE_ID, + it('preserves an explicit reviewer target service id when creating a new handoff', () => { + const handoff = createServiceHandoff({ + chat_jid: 'dc:handoff-stored-reviewer', + group_folder: 'handoff-stored-reviewer', + source_service_id: CLAUDE_SERVICE_ID, + target_service_id: 'stale-reviewer-shadow', + source_role: 'owner', + target_role: 'reviewer', + source_agent_type: 'claude-code', + target_agent_type: 'codex', + prompt: 'stored reviewer service id', + intended_role: 'reviewer', }); - expect(getChannelOwnerLease('dc:canonical-schema')).toMatchObject({ - owner_service_id: CODEX_MAIN_SERVICE_ID, - reviewer_service_id: CLAUDE_SERVICE_ID, - }); - expect(getLatestPairedTaskForChat('dc:canonical-schema')).toMatchObject({ - owner_service_id: CODEX_MAIN_SERVICE_ID, - reviewer_service_id: CLAUDE_SERVICE_ID, - }); - expect(getPendingServiceHandoffs(CLAUDE_SERVICE_ID)).toEqual([ + + expect(handoff.target_service_id).toBe('stale-reviewer-shadow'); + expect(getPendingServiceHandoffs('stale-reviewer-shadow')).toEqual([ expect.objectContaining({ - chat_jid: 'dc:canonical-schema', - source_service_id: CODEX_MAIN_SERVICE_ID, - target_service_id: CLAUDE_SERVICE_ID, - source_agent_type: 'codex', + id: handoff.id, + source_service_id: CLAUDE_SERVICE_ID, + target_service_id: 'stale-reviewer-shadow', + source_role: 'owner', + target_role: 'reviewer', }), ]); - - const migratedDb = new Database(dbPath, { readonly: true }); - expect( - ( - migratedDb.prepare(`PRAGMA table_info(work_items)`).all() as Array<{ - name: string; - }> - ).map((row) => row.name), - ).not.toContain('service_id'); - expect( - ( - migratedDb.prepare(`PRAGMA table_info(channel_owner)`).all() as Array<{ - name: string; - }> - ).map((row) => row.name), - ).not.toContain('owner_service_id'); - expect( - ( - migratedDb.prepare(`PRAGMA table_info(paired_tasks)`).all() as Array<{ - name: string; - }> - ).map((row) => row.name), - ).not.toContain('owner_service_id'); - expect( - ( - migratedDb - .prepare(`PRAGMA table_info(service_handoffs)`) - .all() as Array<{ name: string }> - ).map((row) => row.name), - ).not.toContain('source_service_id'); - expect( - ( - migratedDb - .prepare(`PRAGMA table_info(service_handoffs)`) - .all() as Array<{ name: string }> - ).map((row) => row.name), - ).toContain('source_agent_type'); - migratedDb.close(); }); + }); describe('message seq cursors', () => { @@ -2937,6 +2913,439 @@ describe('message seq cursors', () => { getLatestMessageSeqAtOrBefore('2024-01-01T00:00:02.000Z', 'group@g.us'), ).toBe(2); }); + + it('preserves legacy timestamp order when backfilling seq during init', () => { + const tempDir = fs.mkdtempSync('/tmp/ejclaw-message-seq-order-'); + const dbPath = path.join(tempDir, 'messages.db'); + const legacyDb = new Database(dbPath); + + legacyDb.exec(` + CREATE TABLE messages ( + id TEXT PRIMARY KEY, + chat_jid TEXT NOT NULL, + sender TEXT NOT NULL, + sender_name TEXT NOT NULL, + content TEXT NOT NULL, + timestamp TEXT NOT NULL, + is_from_me INTEGER NOT NULL DEFAULT 0, + is_bot_message INTEGER NOT NULL DEFAULT 0, + seq INTEGER + ); + `); + + legacyDb + .prepare( + `INSERT INTO messages ( + id, chat_jid, sender, sender_name, content, timestamp, is_from_me, is_bot_message, seq + ) VALUES (?, ?, ?, ?, ?, ?, 0, 0, NULL)`, + ) + .run( + 'legacy-late', + 'dc:legacy-seq', + 'user-1', + 'User One', + 'late insert', + '2024-01-01T00:00:02.000Z', + ); + legacyDb + .prepare( + `INSERT INTO messages ( + id, chat_jid, sender, sender_name, content, timestamp, is_from_me, is_bot_message, seq + ) VALUES (?, ?, ?, ?, ?, ?, 0, 0, NULL)`, + ) + .run( + 'legacy-early', + 'dc:legacy-seq', + 'user-2', + 'User Two', + 'early insert', + '2024-01-01T00:00:01.000Z', + ); + legacyDb.close(); + + _initTestDatabaseFromFile(dbPath); + + expect( + getMessagesSinceSeq('dc:legacy-seq', 0, 'Andy').map((message) => ({ + id: message.id, + seq: message.seq, + })), + ).toEqual([ + { id: 'legacy-early', seq: 1 }, + { id: 'legacy-late', seq: 2 }, + ]); + }); + + it('backfills missing legacy seq values after the existing maximum without duplicates', () => { + const tempDir = fs.mkdtempSync('/tmp/ejclaw-message-seq-partial-'); + const dbPath = path.join(tempDir, 'messages.db'); + const legacyDb = new Database(dbPath); + + legacyDb.exec(` + CREATE TABLE messages ( + id TEXT PRIMARY KEY, + chat_jid TEXT NOT NULL, + sender TEXT NOT NULL, + sender_name TEXT NOT NULL, + content TEXT NOT NULL, + timestamp TEXT NOT NULL, + is_from_me INTEGER NOT NULL DEFAULT 0, + is_bot_message INTEGER NOT NULL DEFAULT 0, + seq INTEGER + ); + `); + + legacyDb + .prepare( + `INSERT INTO messages ( + id, chat_jid, sender, sender_name, content, timestamp, is_from_me, is_bot_message, seq + ) VALUES (?, ?, ?, ?, ?, ?, 0, 0, ?)`, + ) + .run( + 'legacy-existing-seq', + 'dc:legacy-partial-seq', + 'user-1', + 'User One', + 'existing seq', + '2024-01-01T00:00:01.000Z', + 1, + ); + legacyDb + .prepare( + `INSERT INTO messages ( + id, chat_jid, sender, sender_name, content, timestamp, is_from_me, is_bot_message, seq + ) VALUES (?, ?, ?, ?, ?, ?, 0, 0, NULL)`, + ) + .run( + 'legacy-missing-seq', + 'dc:legacy-partial-seq', + 'user-2', + 'User Two', + 'missing seq', + '2024-01-01T00:00:00.500Z', + ); + legacyDb.close(); + + _initTestDatabaseFromFile(dbPath); + + expect( + getMessagesSinceSeq('dc:legacy-partial-seq', 0, 'Andy').map((message) => ({ + id: message.id, + seq: message.seq, + })), + ).toEqual([ + { id: 'legacy-existing-seq', seq: 1 }, + { id: 'legacy-missing-seq', seq: 2 }, + ]); + }); + + it('creates new service handoffs after init on a legacy handoff schema', () => { + const tempDir = fs.mkdtempSync('/tmp/ejclaw-legacy-handoff-write-'); + const dbPath = path.join(tempDir, 'messages.db'); + const legacyDb = new Database(dbPath); + + legacyDb.exec(` + CREATE TABLE 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, + source_role 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 + ); + `); + legacyDb.close(); + + _initTestDatabaseFromFile(dbPath); + + const handoff = createServiceHandoff({ + chat_jid: 'dc:legacy-write-handoff', + group_folder: 'legacy-write-handoff', + source_service_id: CLAUDE_SERVICE_ID, + target_service_id: CODEX_REVIEW_SERVICE_ID, + source_role: 'owner', + target_role: 'reviewer', + target_agent_type: 'codex', + prompt: 'legacy handoff write', + intended_role: 'reviewer', + }); + + expect(handoff.target_service_id).toBe(CODEX_REVIEW_SERVICE_ID); + expect(getPendingServiceHandoffs(CODEX_REVIEW_SERVICE_ID)).toEqual([ + expect.objectContaining({ + id: handoff.id, + target_service_id: CODEX_REVIEW_SERVICE_ID, + }), + ]); + }); + + it('creates new service handoffs after init on a canonical handoff schema without service id columns', () => { + const tempDir = fs.mkdtempSync('/tmp/ejclaw-canonical-handoff-write-'); + const dbPath = path.join(tempDir, 'messages.db'); + const legacyDb = new Database(dbPath); + + legacyDb.exec(` + CREATE TABLE service_handoffs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + chat_jid TEXT NOT NULL, + group_folder TEXT NOT NULL, + 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 + ); + `); + legacyDb.close(); + + _initTestDatabaseFromFile(dbPath); + + const handoff = createServiceHandoff({ + chat_jid: 'dc:canonical-write-handoff', + group_folder: 'canonical-write-handoff', + source_role: 'owner', + source_agent_type: 'claude-code', + target_role: 'reviewer', + target_agent_type: 'codex', + prompt: 'canonical handoff write', + intended_role: 'reviewer', + }); + + expect(handoff.source_service_id).toBe(CLAUDE_SERVICE_ID); + expect(handoff.target_service_id).toBe(CODEX_REVIEW_SERVICE_ID); + expect(getPendingServiceHandoffs(CODEX_REVIEW_SERVICE_ID)).toEqual([ + expect.objectContaining({ + id: handoff.id, + target_service_id: CODEX_REVIEW_SERVICE_ID, + }), + ]); + }); +}); + +describe('legacy schema writes after init', () => { + it('creates paired tasks after init on a legacy paired_tasks schema', () => { + const tempDir = fs.mkdtempSync('/tmp/ejclaw-legacy-paired-task-write-'); + const dbPath = path.join(tempDir, 'messages.db'); + const legacyDb = new Database(dbPath); + + legacyDb.exec(` + CREATE TABLE 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, + status TEXT NOT NULL, + arbiter_verdict TEXT, + arbiter_requested_at TEXT, + completion_reason TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + `); + legacyDb.close(); + + _initTestDatabaseFromFile(dbPath); + + createPairedTask({ + id: 'paired-legacy-write', + chat_jid: 'dc:legacy-write', + group_folder: 'legacy-write-room', + owner_service_id: CLAUDE_SERVICE_ID, + reviewer_service_id: CODEX_REVIEW_SERVICE_ID, + owner_agent_type: 'claude-code', + reviewer_agent_type: 'codex', + arbiter_agent_type: ARBITER_AGENT_TYPE ?? null, + title: 'legacy write task', + source_ref: 'HEAD', + plan_notes: null, + review_requested_at: null, + round_trip_count: 0, + status: 'active', + arbiter_verdict: null, + arbiter_requested_at: null, + completion_reason: null, + created_at: '2026-03-28T00:00:00.000Z', + updated_at: '2026-03-28T00:00:00.000Z', + }); + + expect(getPairedTaskById('paired-legacy-write')).toMatchObject({ + owner_service_id: CLAUDE_SERVICE_ID, + reviewer_service_id: CODEX_REVIEW_SERVICE_ID, + owner_agent_type: 'claude-code', + reviewer_agent_type: 'codex', + }); + }); + + it('creates paired tasks after init on a canonical paired_tasks schema without service id columns', () => { + const tempDir = fs.mkdtempSync('/tmp/ejclaw-canonical-paired-task-write-'); + const dbPath = path.join(tempDir, 'messages.db'); + const legacyDb = new Database(dbPath); + + legacyDb.exec(` + CREATE TABLE paired_tasks ( + id TEXT PRIMARY KEY, + chat_jid TEXT NOT NULL, + group_folder 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, + status TEXT NOT NULL, + arbiter_verdict TEXT, + arbiter_requested_at TEXT, + completion_reason TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + `); + legacyDb.close(); + + _initTestDatabaseFromFile(dbPath); + + createPairedTask({ + id: 'paired-canonical-write', + chat_jid: 'dc:canonical-write', + group_folder: 'canonical-write-room', + owner_service_id: CLAUDE_SERVICE_ID, + reviewer_service_id: CODEX_REVIEW_SERVICE_ID, + owner_agent_type: 'claude-code', + reviewer_agent_type: 'codex', + arbiter_agent_type: ARBITER_AGENT_TYPE ?? null, + title: 'canonical write task', + source_ref: 'HEAD', + plan_notes: null, + review_requested_at: null, + round_trip_count: 0, + status: 'active', + arbiter_verdict: null, + arbiter_requested_at: null, + completion_reason: null, + created_at: '2026-03-28T00:00:00.000Z', + updated_at: '2026-03-28T00:00:00.000Z', + }); + + expect(getPairedTaskById('paired-canonical-write')).toMatchObject({ + owner_service_id: CLAUDE_SERVICE_ID, + reviewer_service_id: CODEX_REVIEW_SERVICE_ID, + owner_agent_type: 'claude-code', + reviewer_agent_type: 'codex', + }); + }); + + it('creates channel owner leases after init on a legacy channel_owner schema', () => { + const tempDir = fs.mkdtempSync('/tmp/ejclaw-legacy-channel-owner-write-'); + const dbPath = path.join(tempDir, 'messages.db'); + const legacyDb = new Database(dbPath); + + legacyDb.exec(` + CREATE TABLE 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 + ); + `); + legacyDb.close(); + + _initTestDatabaseFromFile(dbPath); + + setChannelOwnerLease({ + chat_jid: 'dc:legacy-channel-owner-write', + owner_service_id: CLAUDE_SERVICE_ID, + reviewer_service_id: CODEX_REVIEW_SERVICE_ID, + owner_agent_type: 'claude-code', + reviewer_agent_type: 'codex', + activated_at: '2026-03-28T00:00:00.000Z', + reason: 'legacy-write', + }); + + expect(getChannelOwnerLease('dc:legacy-channel-owner-write')).toMatchObject( + { + owner_service_id: CLAUDE_SERVICE_ID, + reviewer_service_id: CODEX_REVIEW_SERVICE_ID, + owner_agent_type: 'claude-code', + reviewer_agent_type: 'codex', + }, + ); + }); + + it('creates channel owner leases after init on a canonical channel_owner schema without service id columns', () => { + const tempDir = fs.mkdtempSync('/tmp/ejclaw-canonical-channel-owner-write-'); + const dbPath = path.join(tempDir, 'messages.db'); + const legacyDb = new Database(dbPath); + + legacyDb.exec(` + CREATE TABLE channel_owner ( + chat_jid TEXT PRIMARY KEY, + owner_agent_type TEXT, + reviewer_agent_type TEXT, + arbiter_agent_type TEXT, + activated_at TEXT, + reason TEXT + ); + `); + legacyDb.close(); + + _initTestDatabaseFromFile(dbPath); + + setChannelOwnerLease({ + chat_jid: 'dc:canonical-channel-owner-write', + owner_service_id: CLAUDE_SERVICE_ID, + reviewer_service_id: CODEX_REVIEW_SERVICE_ID, + owner_agent_type: 'claude-code', + reviewer_agent_type: 'codex', + activated_at: '2026-03-28T00:00:00.000Z', + reason: 'canonical-write', + }); + + expect( + getChannelOwnerLease('dc:canonical-channel-owner-write'), + ).toMatchObject({ + owner_service_id: CLAUDE_SERVICE_ID, + reviewer_service_id: CODEX_REVIEW_SERVICE_ID, + owner_agent_type: 'claude-code', + reviewer_agent_type: 'codex', + }); + }); }); describe('memories', () => { @@ -3082,16 +3491,20 @@ describe('work items', () => { }); expect(item.delivery_role).toBe('reviewer'); - expect(getOpenWorkItem('dc:123', 'claude-code')?.id).toBe(item.id); + expect(getOpenWorkItem('dc:123', 'claude-code', item.service_id)?.id).toBe( + item.id, + ); markWorkItemDeliveryRetry(item.id, 'send failed'); - const retried = getOpenWorkItem('dc:123', 'claude-code'); + const retried = getOpenWorkItem('dc:123', 'claude-code', item.service_id); expect(retried?.status).toBe('delivery_retry'); expect(retried?.delivery_attempts).toBe(1); expect(retried?.last_error).toBe('send failed'); markWorkItemDelivered(item.id, 'msg-1'); - expect(getOpenWorkItem('dc:123', 'claude-code')).toBeUndefined(); + expect( + getOpenWorkItem('dc:123', 'claude-code', item.service_id), + ).toBeUndefined(); }); it('finds pending delivery retries even when they were created by a fallback agent type', () => { @@ -3099,6 +3512,7 @@ describe('work items', () => { group_folder: 'discord_test', chat_jid: 'dc:fallback', agent_type: 'codex', + service_id: SERVICE_SESSION_SCOPE, delivery_role: 'reviewer', start_seq: 20, end_seq: 22, @@ -3112,7 +3526,7 @@ describe('work items', () => { expect(getOpenWorkItemForChat('dc:fallback')).toBeUndefined(); }); - it('stores service shadow as a derived compatibility field from role and agent type', () => { + it('stores service id from role and agent type when an explicit service id is omitted', () => { const reviewerItem = createProducedWorkItem({ group_folder: 'discord_test', chat_jid: 'dc:shadow-reviewer', @@ -3135,4 +3549,149 @@ describe('work items', () => { expect(reviewerItem.service_id).toBe(CODEX_REVIEW_SERVICE_ID); expect(ownerItem.service_id).toBe(CODEX_MAIN_SERVICE_ID); }); + + it('routes open work items by stored service id before falling back to role shadow inference', () => { + const reviewerItem = createProducedWorkItem({ + group_folder: 'discord_test', + chat_jid: 'dc:stored-service-reviewer', + agent_type: 'codex', + service_id: 'stale-reviewer-shadow', + delivery_role: 'reviewer', + start_seq: 1, + end_seq: 2, + result_payload: 'reviewer output', + }); + createProducedWorkItem({ + group_folder: 'discord_test', + chat_jid: 'dc:stored-service-reviewer', + agent_type: 'codex', + service_id: CODEX_MAIN_SERVICE_ID, + delivery_role: 'owner', + start_seq: 3, + end_seq: 4, + result_payload: 'owner output', + }); + + expect( + getOpenWorkItem( + 'dc:stored-service-reviewer', + 'codex', + 'stale-reviewer-shadow', + )?.id, + ).toBe(reviewerItem.id); + }); + + it('returns undefined when no open work item matches the requested service id or fallback role', () => { + createProducedWorkItem({ + group_folder: 'discord_test', + chat_jid: 'dc:stored-service-mismatch', + agent_type: 'codex', + service_id: 'stale-reviewer-shadow', + delivery_role: 'reviewer', + start_seq: 1, + end_seq: 2, + result_payload: 'reviewer output', + }); + + expect( + getOpenWorkItem('dc:stored-service-mismatch', 'codex', CODEX_MAIN_SERVICE_ID), + ).toBeUndefined(); + expect(getOpenWorkItemForChat('dc:stored-service-mismatch')).toBeUndefined(); + }); + + it('allows a current-service open work item even when a stale-service open row already exists', () => { + createProducedWorkItem({ + group_folder: 'discord_test', + chat_jid: 'dc:repro', + agent_type: 'codex', + service_id: 'stale-reviewer-shadow', + delivery_role: 'reviewer', + start_seq: 1, + end_seq: 2, + result_payload: 'stale reviewer output', + }); + + expect(getOpenWorkItemForChat('dc:repro', CODEX_MAIN_SERVICE_ID)).toBeUndefined(); + + const currentItem = createProducedWorkItem({ + group_folder: 'discord_test', + chat_jid: 'dc:repro', + agent_type: 'codex', + service_id: CODEX_MAIN_SERVICE_ID, + delivery_role: 'reviewer', + start_seq: 3, + end_seq: 4, + result_payload: 'current reviewer output', + }); + + expect( + getOpenWorkItemForChat('dc:repro', CODEX_MAIN_SERVICE_ID)?.id, + ).toBe(currentItem.id); + }); + + it('backfills work item service ids during init on a canonical work_items schema without service_id columns', () => { + const tempDir = fs.mkdtempSync('/tmp/ejclaw-work-items-canonical-'); + const dbPath = path.join(tempDir, 'messages.db'); + const legacyDb = new Database(dbPath); + + legacyDb.exec(` + CREATE TABLE work_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + group_folder TEXT NOT NULL, + chat_jid TEXT NOT NULL, + agent_type TEXT NOT NULL, + delivery_role TEXT, + status TEXT NOT NULL DEFAULT 'produced', + start_seq INTEGER, + end_seq INTEGER, + result_payload TEXT NOT NULL, + delivery_attempts INTEGER NOT NULL DEFAULT 0, + delivery_message_id TEXT, + last_error TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + delivered_at TEXT + ); + `); + legacyDb + .prepare( + `INSERT INTO work_items ( + group_folder, + chat_jid, + agent_type, + delivery_role, + status, + start_seq, + end_seq, + result_payload, + delivery_attempts, + created_at, + updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + 'discord_test', + 'dc:legacy-work-item', + 'codex', + 'reviewer', + 'produced', + 1, + 2, + 'legacy reviewer output', + 0, + '2026-03-28T00:00:00.000Z', + '2026-03-28T00:00:00.000Z', + ); + legacyDb.close(); + + _initTestDatabaseFromFile(dbPath); + + expect( + getOpenWorkItem('dc:legacy-work-item', 'codex', CODEX_REVIEW_SERVICE_ID), + ).toMatchObject({ + delivery_role: 'reviewer', + service_id: CODEX_REVIEW_SERVICE_ID, + result_payload: 'legacy reviewer output', + }); + }); }); diff --git a/src/db.ts b/src/db.ts index 0836e36..8f870ee 100644 --- a/src/db.ts +++ b/src/db.ts @@ -144,15 +144,6 @@ import { setRouterStateInDatabase, } from './db/router-state.js'; import { - backfillChannelOwnerRoleMetadata, - backfillPairedTaskRoleMetadata, - backfillServiceHandoffServiceShadows, - backfillStoredRoomSettings, - backfillWorkItemServiceShadows, - rebuildChannelOwnerCanonicalSchema, - rebuildPairedTasksCanonicalSchema, - rebuildServiceHandoffsCanonicalSchema, - rebuildWorkItemsCanonicalSchema, resolveStablePairedTaskOwnerAgentType, resolveStableRoomRoleAgentType, } from './db/legacy-rebuilds.js'; @@ -163,7 +154,7 @@ import { getSessionFromDatabase, setSessionInDatabase, } from './db/sessions.js'; -import { type SchemaMigrationHooks, tableHasColumn } from './db/schema.js'; +import { tableHasColumn } from './db/schema.js'; import { type CreateScheduledTaskInput, type ScheduledTaskStatusTrackingUpdates, @@ -233,62 +224,10 @@ export type { WorkItem } from './db/work-items.js'; export type { ChannelOwnerLeaseRow } from './db/channel-owner-leases.js'; export type { ServiceHandoff } from './db/service-handoffs.js'; -function backfillMessageSeq(database: Database): void { - const rows = database - .prepare( - `SELECT rowid, seq - FROM messages - ORDER BY CASE WHEN seq IS NULL THEN 1 ELSE 0 END, seq, timestamp, rowid`, - ) - .all() as Array<{ rowid: number; seq: number | null }>; - - if (rows.length === 0) { - return; - } - - let nextSeq = 1; - const assignSeq = database.prepare( - 'UPDATE messages SET seq = ? WHERE rowid = ? AND seq IS NULL', - ); - const tx = database.transaction(() => { - for (const row of rows) { - if (row.seq === null) { - assignSeq.run(nextSeq, row.rowid); - } - nextSeq = Math.max(nextSeq, (row.seq ?? nextSeq) + 1); - } - }); - tx(); - - const maxSeqRow = database - .prepare('SELECT MAX(seq) AS maxSeq FROM messages') - .get() as { maxSeq: number | null }; - const maxSeq = maxSeqRow.maxSeq ?? 0; - if (maxSeq > 0) { - database - .prepare('INSERT OR IGNORE INTO message_sequence (id) VALUES (?)') - .run(maxSeq); - } -} - -function getSchemaMigrationHooks(): SchemaMigrationHooks { - return { - backfillMessageSeq, - backfillStoredRoomSettings, - backfillChannelOwnerRoleMetadata, - backfillWorkItemServiceShadows, - backfillServiceHandoffServiceShadows, - backfillPairedTaskRoleMetadata, - rebuildWorkItemsCanonicalSchema, - rebuildChannelOwnerCanonicalSchema, - rebuildPairedTasksCanonicalSchema, - rebuildServiceHandoffsCanonicalSchema, - }; -} - export function initDatabase(): void { db = openPersistentDatabase(); - initializeDatabaseSchema(db, getSchemaMigrationHooks()); + initializeDatabaseSchema(db); + syncLegacyRegisteredGroupsIntoStoredRooms(); clearPairedTaskExecutionLeasesForServiceInDatabase( db, normalizeServiceId(SERVICE_ID), @@ -304,7 +243,8 @@ export function initDatabase(): void { /** @internal - for tests only. Creates a fresh in-memory database. */ export function _initTestDatabase(): void { db = openInMemoryDatabase(); - initializeDatabaseSchema(db, getSchemaMigrationHooks()); + initializeDatabaseSchema(db); + syncLegacyRegisteredGroupsIntoStoredRooms(); clearPairedTaskExecutionLeasesForServiceInDatabase( db, normalizeServiceId(SERVICE_ID), @@ -315,7 +255,8 @@ export function _initTestDatabase(): void { /** @internal - for tests only. Opens an existing database file and runs schema/migrations. */ export function _initTestDatabaseFromFile(dbPath: string): void { db = openDatabaseFromFile(dbPath); - initializeDatabaseSchema(db, getSchemaMigrationHooks()); + initializeDatabaseSchema(db); + syncLegacyRegisteredGroupsIntoStoredRooms(); clearPairedTaskExecutionLeasesForServiceInDatabase( db, normalizeServiceId(SERVICE_ID), @@ -540,8 +481,11 @@ export function getOpenWorkItem( return getOpenWorkItemFromDatabase(db, chatJid, agentType, serviceId); } -export function getOpenWorkItemForChat(chatJid: string): WorkItem | undefined { - return getOpenWorkItemForChatFromDatabase(db, chatJid); +export function getOpenWorkItemForChat( + chatJid: string, + serviceId: string = SERVICE_SESSION_SCOPE, +): WorkItem | undefined { + return getOpenWorkItemForChatFromDatabase(db, chatJid, serviceId); } export function createProducedWorkItem( @@ -1053,6 +997,25 @@ function syncStoredRoomRegistrationSnapshotForJid(chatJid: string): void { updateStoredRoomMetadata(db, chatJid, snapshot); } +function syncLegacyRegisteredGroupsIntoStoredRooms(): void { + const rows = db + .prepare( + `SELECT DISTINCT jid + FROM registered_groups + ORDER BY jid`, + ) + .all() as Array<{ jid: string }>; + if (rows.length === 0) { + return; + } + + db.transaction((registeredGroupRows: Array<{ jid: string }>) => { + for (const row of registeredGroupRows) { + syncStoredRoomRegistrationSnapshotForJid(row.jid); + } + })(rows); +} + function buildRoomRegistrationSnapshotFromStoredRoom( stored: StoredRoomSettings, overrides?: Partial>, diff --git a/src/db/base-schema.ts b/src/db/base-schema.ts index c647e28..a712b9f 100644 --- a/src/db/base-schema.ts +++ b/src/db/base-schema.ts @@ -32,6 +32,7 @@ export function applyBaseSchema(database: Database): void { 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, @@ -121,6 +122,8 @@ export function applyBaseSchema(database: Database): void { 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, @@ -223,6 +226,9 @@ export function applyBaseSchema(database: Database): void { 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, @@ -251,6 +257,8 @@ export function applyBaseSchema(database: Database): void { 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, source_role TEXT, source_agent_type TEXT, target_role TEXT, diff --git a/src/db/bootstrap.ts b/src/db/bootstrap.ts index 22cb263..e7c163f 100644 --- a/src/db/bootstrap.ts +++ b/src/db/bootstrap.ts @@ -7,7 +7,7 @@ import { logger } from '../logger.js'; import type { RegisteredGroup } from '../types.js'; import { readJsonFile } from '../utils.js'; import { applyBaseSchema } from './base-schema.js'; -import { type SchemaMigrationHooks, applySchemaMigrations } from './schema.js'; +import { applySchemaMigrations } from './schema.js'; export interface JsonStateMigrationHooks { setRouterState(key: string, value: string): void; @@ -18,14 +18,10 @@ export interface JsonStateMigrationHooks { ): void; } -export function initializeDatabaseSchema( - database: Database, - hooks: SchemaMigrationHooks, -): void { +export function initializeDatabaseSchema(database: Database): void { applyBaseSchema(database); applySchemaMigrations(database, { assistantName: ASSISTANT_NAME, - hooks, }); } diff --git a/src/db/channel-owner-leases.ts b/src/db/channel-owner-leases.ts index 10dd989..256cebd 100644 --- a/src/db/channel-owner-leases.ts +++ b/src/db/channel-owner-leases.ts @@ -27,6 +27,9 @@ export interface ChannelOwnerLeaseRow { interface StoredChannelOwnerLeaseRow { chat_jid: string; + owner_service_id?: string | null; + reviewer_service_id?: string | null; + arbiter_service_id?: string | null; owner_agent_type?: string | null; reviewer_agent_type?: string | null; arbiter_agent_type?: string | null; @@ -49,32 +52,55 @@ export interface SetChannelOwnerLeaseInput { function hydrateChannelOwnerLeaseRow( row: StoredChannelOwnerLeaseRow, ): ChannelOwnerLeaseRow { + const persistedOwnerAgentType = normalizeStoredAgentType( + row.owner_agent_type, + ); + const persistedReviewerAgentType = normalizeStoredAgentType( + row.reviewer_agent_type, + ); + const persistedArbiterAgentType = normalizeStoredAgentType( + row.arbiter_agent_type, + ); const ownerAgentType = - normalizeStoredAgentType(row.owner_agent_type) ?? OWNER_AGENT_TYPE; + persistedOwnerAgentType ?? + (row.owner_service_id + ? inferAgentTypeFromServiceShadow(row.owner_service_id) + : undefined) ?? + OWNER_AGENT_TYPE; const reviewerAgentType = - row.reviewer_agent_type == null + row.reviewer_agent_type == null && row.reviewer_service_id == null ? null - : (normalizeStoredAgentType(row.reviewer_agent_type) ?? + : (persistedReviewerAgentType ?? + (row.reviewer_service_id + ? inferAgentTypeFromServiceShadow(row.reviewer_service_id) + : null) ?? resolveStableReviewerAgentType(ownerAgentType, null)); const arbiterAgentType = - row.arbiter_agent_type == null + row.arbiter_agent_type == null && row.arbiter_service_id == null ? null - : (normalizeStoredAgentType(row.arbiter_agent_type) ?? + : (persistedArbiterAgentType ?? + (row.arbiter_service_id + ? inferAgentTypeFromServiceShadow(row.arbiter_service_id) + : undefined) ?? ARBITER_AGENT_TYPE ?? null); return { chat_jid: row.chat_jid, owner_service_id: - resolveRoleServiceShadow('owner', ownerAgentType) ?? CLAUDE_SERVICE_ID, + row.owner_service_id ?? + resolveRoleServiceShadow('owner', ownerAgentType) ?? + CLAUDE_SERVICE_ID, reviewer_service_id: - reviewerAgentType == null + row.reviewer_service_id ?? + (reviewerAgentType == null ? null - : resolveRoleServiceShadow('reviewer', reviewerAgentType), + : resolveRoleServiceShadow('reviewer', reviewerAgentType)), arbiter_service_id: - arbiterAgentType == null + row.arbiter_service_id ?? + (arbiterAgentType == null ? null - : resolveRoleServiceShadow('arbiter', arbiterAgentType), + : resolveRoleServiceShadow('arbiter', arbiterAgentType)), owner_agent_type: ownerAgentType, reviewer_agent_type: reviewerAgentType, arbiter_agent_type: arbiterAgentType, @@ -88,17 +114,7 @@ export function getChannelOwnerLeaseFromDatabase( chatJid: string, ): ChannelOwnerLeaseRow | undefined { const row = database - .prepare( - `SELECT - chat_jid, - owner_agent_type, - reviewer_agent_type, - arbiter_agent_type, - activated_at, - reason - FROM channel_owner - WHERE chat_jid = ?`, - ) + .prepare(`SELECT * FROM channel_owner WHERE chat_jid = ?`) .get(chatJid) as StoredChannelOwnerLeaseRow | undefined; return row ? hydrateChannelOwnerLeaseRow(row) : undefined; } @@ -107,16 +123,7 @@ export function getAllChannelOwnerLeasesFromDatabase( database: Database, ): ChannelOwnerLeaseRow[] { const rows = database - .prepare( - `SELECT - chat_jid, - owner_agent_type, - reviewer_agent_type, - arbiter_agent_type, - activated_at, - reason - FROM channel_owner`, - ) + .prepare(`SELECT * FROM channel_owner`) .all() as StoredChannelOwnerLeaseRow[]; return rows.map(hydrateChannelOwnerLeaseRow); } @@ -142,20 +149,44 @@ export function setChannelOwnerLeaseInDatabase( inferAgentTypeFromServiceShadow(input.arbiter_service_id ?? null) ?? ARBITER_AGENT_TYPE ?? null); + const ownerServiceId = + input.owner_service_id ?? + resolveRoleServiceShadow('owner', ownerAgentType) ?? + CLAUDE_SERVICE_ID; + const reviewerServiceId = + input.reviewer_service_id == null && reviewerAgentType == null + ? null + : (input.reviewer_service_id ?? + (reviewerAgentType == null + ? null + : resolveRoleServiceShadow('reviewer', reviewerAgentType))); + const arbiterServiceId = + input.arbiter_service_id == null && arbiterAgentType == null + ? null + : (input.arbiter_service_id ?? + (arbiterAgentType == null + ? null + : resolveRoleServiceShadow('arbiter', arbiterAgentType))); database .prepare( `INSERT OR REPLACE INTO channel_owner ( chat_jid, + owner_service_id, + reviewer_service_id, + arbiter_service_id, owner_agent_type, reviewer_agent_type, arbiter_agent_type, activated_at, reason - ) VALUES (?, ?, ?, ?, ?, ?)`, + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) .run( input.chat_jid, + ownerServiceId, + reviewerServiceId, + arbiterServiceId, ownerAgentType ?? null, reviewerAgentType ?? null, arbiterAgentType ?? null, diff --git a/src/db/legacy-rebuilds.ts b/src/db/legacy-rebuilds.ts index 93a73db..863c1f5 100644 --- a/src/db/legacy-rebuilds.ts +++ b/src/db/legacy-rebuilds.ts @@ -2,26 +2,15 @@ import { Database } from 'bun:sqlite'; import { ARBITER_AGENT_TYPE, - OWNER_AGENT_TYPE, REVIEWER_AGENT_TYPE, - SERVICE_SESSION_SCOPE, } from '../config.js'; import { collectRegisteredAgentTypes, collectRegisteredAgentTypesForFolder, - collectRoomRegistrationSnapshot, getStoredRoomSettingsRowFromDatabase, inferOwnerAgentTypeFromRegisteredAgentTypes, - inferRoomModeFromRegisteredAgentTypes, - insertStoredRoomSettings, normalizeStoredAgentType, - updateStoredRoomMetadata, } from './room-registration.js'; -import { tableHasColumn } from './schema.js'; -import { - inferAgentTypeFromServiceShadow, - resolveRoleServiceShadow, -} from '../role-service-shadow.js'; import type { AgentType, PairedRoomRole } from '../types.js'; interface StablePairedTaskRowInput { @@ -30,81 +19,6 @@ interface StablePairedTaskRowInput { owner_agent_type?: string | null; } -interface StableLeaseRoleRowInput { - chat_jid: string; - owner_service_id?: string | null; - reviewer_service_id?: string | null; - arbiter_service_id?: string | null; - owner_agent_type?: string | null; - reviewer_agent_type?: string | null; - arbiter_agent_type?: string | null; -} - -interface WorkItemServiceShadowRow { - id: number; - agent_type: string; - service_id: string; - delivery_role: PairedRoomRole | null; -} - -interface LegacyServiceHandoffServiceRow { - id: number; - chat_jid: string; - group_folder: string; - source_service_id?: string | null; - target_service_id?: string | null; - source_role: PairedRoomRole | null; - source_agent_type?: string | null; - target_role: PairedRoomRole | null; - target_agent_type?: string | null; -} - -interface LegacyPairedTaskServiceRow { - id: string; - chat_jid: string; - group_folder: string; - owner_service_id: string; - reviewer_service_id: string; - owner_agent_type?: string | null; - reviewer_agent_type?: string | null; - arbiter_agent_type?: string | null; -} - -export function backfillStoredRoomSettings(database: Database): void { - const rows = database - .prepare(`SELECT DISTINCT jid FROM registered_groups`) - .all() as Array<{ jid: string }>; - if (rows.length === 0) return; - - const tx = database.transaction((registeredRows: Array<{ jid: string }>) => { - for (const row of registeredRows) { - const existing = getStoredRoomSettingsRowFromDatabase(database, row.jid); - const snapshot = collectRoomRegistrationSnapshot( - database, - row.jid, - existing, - ); - if (!snapshot) continue; - - if (existing) { - updateStoredRoomMetadata(database, row.jid, snapshot); - continue; - } - - insertStoredRoomSettings( - database, - row.jid, - inferRoomModeFromRegisteredAgentTypes( - collectRegisteredAgentTypes(database, row.jid), - ), - 'inferred', - snapshot, - ); - } - }); - tx(rows); -} - export function resolveStablePairedTaskOwnerAgentType( database: Database, task: StablePairedTaskRowInput, @@ -183,585 +97,3 @@ export function resolveStableRoomRoleAgentType( return ARBITER_AGENT_TYPE ?? null; } - -function resolveStableLeaseOwnerAgentType( - database: Database, - row: StableLeaseRoleRowInput, -): AgentType | undefined { - const persisted = normalizeStoredAgentType(row.owner_agent_type); - if (persisted) { - return persisted; - } - const stored = getStoredRoomSettingsRowFromDatabase(database, row.chat_jid); - if (stored?.ownerAgentType) { - return stored.ownerAgentType; - } - return inferAgentTypeFromServiceShadow(row.owner_service_id); -} - -function resolveStableLeaseRoleAgentType( - database: Database, - row: StableLeaseRoleRowInput, - role: PairedRoomRole, -): AgentType | null | undefined { - if (role === 'owner') { - return resolveStableLeaseOwnerAgentType(database, row); - } - if (role === 'reviewer') { - if (row.reviewer_service_id == null) { - return null; - } - return ( - normalizeStoredAgentType(row.reviewer_agent_type) ?? - inferAgentTypeFromServiceShadow(row.reviewer_service_id) ?? - resolveStableReviewerAgentType( - resolveStableLeaseOwnerAgentType(database, row), - null, - ) - ); - } - return ( - normalizeStoredAgentType(row.arbiter_agent_type) ?? - (row.arbiter_service_id - ? inferAgentTypeFromServiceShadow(row.arbiter_service_id) - : undefined) ?? - ARBITER_AGENT_TYPE ?? - null - ); -} - -export function backfillChannelOwnerRoleMetadata(database: Database): void { - const rows = database - .prepare( - `SELECT - chat_jid, - owner_service_id, - reviewer_service_id, - arbiter_service_id, - owner_agent_type, - reviewer_agent_type, - arbiter_agent_type - FROM channel_owner`, - ) - .all() as StableLeaseRoleRowInput[]; - - const update = database.prepare( - `UPDATE channel_owner - SET owner_service_id = ?, - reviewer_service_id = ?, - arbiter_service_id = ?, - owner_agent_type = ?, - reviewer_agent_type = ?, - arbiter_agent_type = ? - WHERE chat_jid = ?`, - ); - - const tx = database.transaction((leaseRows: StableLeaseRoleRowInput[]) => { - for (const row of leaseRows) { - const ownerAgentType = resolveStableLeaseRoleAgentType( - database, - row, - 'owner', - ); - const reviewerAgentType = resolveStableLeaseRoleAgentType( - database, - row, - 'reviewer', - ); - const arbiterAgentType = resolveStableLeaseRoleAgentType( - database, - row, - 'arbiter', - ); - - const ownerServiceId = - resolveRoleServiceShadow('owner', ownerAgentType) ?? - row.owner_service_id ?? - null; - const reviewerServiceId = - row.reviewer_service_id == null - ? null - : (resolveRoleServiceShadow('reviewer', reviewerAgentType) ?? - row.reviewer_service_id); - const arbiterServiceId = - row.arbiter_service_id == null - ? null - : (resolveRoleServiceShadow('arbiter', arbiterAgentType) ?? - row.arbiter_service_id); - - if ( - ownerServiceId === row.owner_service_id && - reviewerServiceId === row.reviewer_service_id && - arbiterServiceId === row.arbiter_service_id && - (ownerAgentType ?? null) === (row.owner_agent_type ?? null) && - (reviewerAgentType ?? null) === (row.reviewer_agent_type ?? null) && - (arbiterAgentType ?? null) === (row.arbiter_agent_type ?? null) - ) { - continue; - } - - update.run( - ownerServiceId, - reviewerServiceId, - arbiterServiceId, - ownerAgentType ?? null, - reviewerAgentType ?? null, - arbiterAgentType ?? null, - row.chat_jid, - ); - } - }); - - tx(rows); -} - -export function resolveWorkItemServiceShadow( - agentType: AgentType, - deliveryRole?: PairedRoomRole | null, -): string { - return ( - resolveRoleServiceShadow(deliveryRole ?? 'owner', agentType) ?? - SERVICE_SESSION_SCOPE - ); -} - -export function backfillWorkItemServiceShadows(database: Database): void { - const rows = database - .prepare( - `SELECT id, agent_type, service_id, delivery_role - FROM work_items`, - ) - .all() as WorkItemServiceShadowRow[]; - - const update = database.prepare( - `UPDATE work_items - SET service_id = ? - WHERE id = ?`, - ); - - const tx = database.transaction( - (workItemRows: WorkItemServiceShadowRow[]) => { - for (const row of workItemRows) { - const agentType = normalizeStoredAgentType(row.agent_type); - if (!agentType) { - continue; - } - const normalizedServiceId = resolveWorkItemServiceShadow( - agentType, - row.delivery_role, - ); - if (normalizedServiceId === row.service_id) { - continue; - } - update.run(normalizedServiceId, row.id); - } - }, - ); - - tx(rows); -} - -export function backfillServiceHandoffServiceShadows(database: Database): void { - const rows = database - .prepare( - `SELECT - id, - chat_jid, - group_folder, - source_service_id, - target_service_id, - source_role, - source_agent_type, - target_role, - target_agent_type - FROM service_handoffs`, - ) - .all() as LegacyServiceHandoffServiceRow[]; - - const update = database.prepare( - `UPDATE service_handoffs - SET source_service_id = ?, - target_service_id = ?, - source_agent_type = ?, - target_agent_type = ? - WHERE id = ?`, - ); - - const tx = database.transaction( - (handoffRows: Array) => { - for (const row of handoffRows) { - const sourceAgentType = - normalizeStoredAgentType(row.source_agent_type) ?? - (row.source_role - ? resolveStableRoomRoleAgentType(database, { - chatJid: row.chat_jid, - groupFolder: row.group_folder, - role: row.source_role, - }) - : null) ?? - inferAgentTypeFromServiceShadow(row.source_service_id); - const targetAgentType = - normalizeStoredAgentType(row.target_agent_type) ?? - (row.target_role - ? resolveStableRoomRoleAgentType(database, { - chatJid: row.chat_jid, - groupFolder: row.group_folder, - role: row.target_role, - }) - : null); - - const normalizedSourceServiceId = - row.source_role != null - ? (resolveRoleServiceShadow(row.source_role, sourceAgentType) ?? - row.source_service_id) - : row.source_service_id; - const normalizedTargetServiceId = - row.target_role != null - ? (resolveRoleServiceShadow(row.target_role, targetAgentType) ?? - row.target_service_id) - : row.target_service_id; - - if ( - normalizedSourceServiceId === row.source_service_id && - normalizedTargetServiceId === row.target_service_id && - (sourceAgentType ?? null) === (row.source_agent_type ?? null) && - (targetAgentType ?? null) === (row.target_agent_type ?? null) - ) { - continue; - } - - update.run( - normalizedSourceServiceId ?? SERVICE_SESSION_SCOPE, - normalizedTargetServiceId ?? SERVICE_SESSION_SCOPE, - sourceAgentType ?? null, - targetAgentType ?? null, - row.id, - ); - } - }, - ); - - tx(rows); -} - -export function backfillPairedTaskRoleMetadata(database: Database): void { - const rows = database - .prepare( - `SELECT - id, - chat_jid, - group_folder, - owner_service_id, - reviewer_service_id, - owner_agent_type, - reviewer_agent_type, - arbiter_agent_type - FROM paired_tasks`, - ) - .all() as LegacyPairedTaskServiceRow[]; - - const tx = database.transaction((taskRows: LegacyPairedTaskServiceRow[]) => { - const update = database.prepare( - `UPDATE paired_tasks - SET owner_service_id = ?, - reviewer_service_id = ?, - owner_agent_type = ?, - reviewer_agent_type = ?, - arbiter_agent_type = ? - WHERE id = ?`, - ); - - for (const row of taskRows) { - const ownerAgentType = resolveStablePairedTaskOwnerAgentType( - database, - row, - ); - const reviewerAgentType = resolveStableReviewerAgentType( - ownerAgentType, - row.reviewer_agent_type ?? null, - ); - const arbiterAgentType = - normalizeStoredAgentType(row.arbiter_agent_type) ?? - ARBITER_AGENT_TYPE ?? - null; - - const ownerServiceId = - resolveRoleServiceShadow('owner', ownerAgentType) ?? - row.owner_service_id; - const reviewerServiceId = - resolveRoleServiceShadow('reviewer', reviewerAgentType) ?? - row.reviewer_service_id; - - update.run( - ownerServiceId, - reviewerServiceId, - ownerAgentType ?? null, - reviewerAgentType ?? null, - arbiterAgentType, - row.id, - ); - } - }); - - tx(rows); -} - -export function rebuildWorkItemsCanonicalSchema(database: Database): void { - if (!tableHasColumn(database, 'work_items', 'service_id')) { - return; - } - - database.exec(` - CREATE TABLE work_items_new ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - group_folder TEXT NOT NULL, - chat_jid TEXT NOT NULL, - agent_type TEXT NOT NULL, - delivery_role TEXT, - status TEXT NOT NULL DEFAULT 'produced', - start_seq INTEGER, - end_seq INTEGER, - result_payload TEXT NOT NULL, - 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) - ); - INSERT INTO work_items_new ( - id, - group_folder, - chat_jid, - agent_type, - delivery_role, - status, - start_seq, - end_seq, - result_payload, - delivery_attempts, - delivery_message_id, - last_error, - created_at, - updated_at, - delivered_at - ) - SELECT - id, - group_folder, - chat_jid, - agent_type, - delivery_role, - status, - start_seq, - end_seq, - result_payload, - delivery_attempts, - delivery_message_id, - last_error, - created_at, - updated_at, - delivered_at - FROM work_items; - DROP TABLE work_items; - ALTER TABLE work_items_new RENAME TO work_items; - 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'); - `); -} - -export function rebuildChannelOwnerCanonicalSchema(database: Database): void { - if (!tableHasColumn(database, 'channel_owner', 'owner_service_id')) { - return; - } - - database.exec(` - CREATE TABLE channel_owner_new ( - chat_jid TEXT PRIMARY KEY, - 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) - ); - INSERT INTO channel_owner_new ( - chat_jid, - owner_agent_type, - reviewer_agent_type, - arbiter_agent_type, - activated_at, - reason - ) - SELECT - chat_jid, - owner_agent_type, - reviewer_agent_type, - arbiter_agent_type, - activated_at, - reason - FROM channel_owner; - DROP TABLE channel_owner; - ALTER TABLE channel_owner_new RENAME TO channel_owner; - `); -} - -export function rebuildPairedTasksCanonicalSchema(database: Database): void { - if (!tableHasColumn(database, 'paired_tasks', 'owner_service_id')) { - return; - } - - database.exec(` - CREATE TABLE paired_tasks_new ( - id TEXT PRIMARY KEY, - chat_jid TEXT NOT NULL, - group_folder 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, - 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) - ); - INSERT INTO paired_tasks_new ( - id, - chat_jid, - group_folder, - owner_agent_type, - reviewer_agent_type, - arbiter_agent_type, - title, - source_ref, - plan_notes, - review_requested_at, - round_trip_count, - status, - arbiter_verdict, - arbiter_requested_at, - completion_reason, - created_at, - updated_at - ) - SELECT - id, - chat_jid, - group_folder, - owner_agent_type, - reviewer_agent_type, - arbiter_agent_type, - title, - source_ref, - plan_notes, - review_requested_at, - round_trip_count, - status, - arbiter_verdict, - arbiter_requested_at, - completion_reason, - created_at, - updated_at - FROM paired_tasks; - DROP TABLE paired_tasks; - ALTER TABLE paired_tasks_new RENAME TO paired_tasks; - CREATE INDEX IF NOT EXISTS idx_paired_tasks_chat_status - ON paired_tasks(chat_jid, status, updated_at); - `); -} - -export function rebuildServiceHandoffsCanonicalSchema( - database: Database, -): void { - if (!tableHasColumn(database, 'service_handoffs', 'source_service_id')) { - return; - } - - database.exec(` - CREATE TABLE service_handoffs_new ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - chat_jid TEXT NOT NULL, - group_folder TEXT NOT NULL, - 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, - CHECK (status IN ('pending', 'claimed', 'completed', 'failed')), - CHECK (intended_role IN ('owner', 'reviewer', 'arbiter') OR intended_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), - CHECK (source_agent_type IN ('claude-code', 'codex') OR source_agent_type IS NULL) - ); - INSERT INTO service_handoffs_new ( - id, - chat_jid, - group_folder, - source_role, - source_agent_type, - target_role, - target_agent_type, - prompt, - status, - start_seq, - end_seq, - reason, - intended_role, - created_at, - claimed_at, - completed_at, - last_error - ) - SELECT - id, - chat_jid, - group_folder, - source_role, - source_agent_type, - target_role, - target_agent_type, - prompt, - status, - start_seq, - end_seq, - reason, - intended_role, - created_at, - claimed_at, - completed_at, - last_error - FROM service_handoffs; - DROP TABLE service_handoffs; - ALTER TABLE service_handoffs_new RENAME TO service_handoffs; - CREATE INDEX IF NOT EXISTS idx_service_handoffs_target - ON service_handoffs(status, target_role, target_agent_type, created_at); - `); -} diff --git a/src/db/paired-state.ts b/src/db/paired-state.ts index 194b907..bbd8dbb 100644 --- a/src/db/paired-state.ts +++ b/src/db/paired-state.ts @@ -12,7 +12,10 @@ import { resolveStableReviewerAgentType, } from './legacy-rebuilds.js'; import { normalizeStoredAgentType } from './room-registration.js'; -import { resolveRoleServiceShadow } from '../role-service-shadow.js'; +import { + inferAgentTypeFromServiceShadow, + resolveRoleServiceShadow, +} from '../role-service-shadow.js'; import { AgentType, PairedProject, @@ -31,6 +34,8 @@ interface StoredPairedTaskRow extends Omit< | 'reviewer_agent_type' | 'arbiter_agent_type' > { + owner_service_id?: string | null; + reviewer_service_id?: string | null; owner_agent_type?: string | null; reviewer_agent_type?: string | null; arbiter_agent_type?: string | null; @@ -81,11 +86,32 @@ function hydratePairedTaskRow( database: Database, row: StoredPairedTaskRow, ): PairedTask { - const ownerAgentType = resolveStablePairedTaskOwnerAgentType(database, row); - const reviewerAgentType = resolveStableReviewerAgentType( - ownerAgentType, + const persistedOwnerAgentType = normalizeStoredAgentType( + row.owner_agent_type ?? null, + ); + const persistedReviewerAgentType = normalizeStoredAgentType( row.reviewer_agent_type ?? null, ); + const stableOwnerAgentType = resolveStablePairedTaskOwnerAgentType( + database, + row, + ); + const ownerAgentType = + stableOwnerAgentType ?? + (row.owner_service_id + ? inferAgentTypeFromServiceShadow(row.owner_service_id) + : undefined); + const stableReviewerAgentType = resolveStableReviewerAgentType( + stableOwnerAgentType, + row.reviewer_agent_type ?? null, + ); + const reviewerAgentType = + persistedReviewerAgentType ?? + stableReviewerAgentType ?? + (row.reviewer_service_id + ? inferAgentTypeFromServiceShadow(row.reviewer_service_id) + : null) ?? + resolveStableReviewerAgentType(ownerAgentType, null); const arbiterAgentType = normalizeStoredAgentType(row.arbiter_agent_type) ?? ARBITER_AGENT_TYPE ?? @@ -94,9 +120,11 @@ function hydratePairedTaskRow( return { ...row, owner_service_id: + row.owner_service_id ?? resolveRoleServiceShadow('owner', ownerAgentType) ?? CODEX_MAIN_SERVICE_ID, reviewer_service_id: + row.reviewer_service_id ?? resolveRoleServiceShadow('reviewer', reviewerAgentType) ?? CODEX_REVIEW_SERVICE_ID, owner_agent_type: ownerAgentType ?? null, @@ -148,6 +176,27 @@ export function createPairedTaskInDatabase( database: Database, task: PairedTask, ): void { + const ownerAgentType = + normalizeStoredAgentType(task.owner_agent_type) ?? + inferAgentTypeFromServiceShadow(task.owner_service_id) ?? + null; + const reviewerAgentType = + normalizeStoredAgentType(task.reviewer_agent_type) ?? + inferAgentTypeFromServiceShadow(task.reviewer_service_id) ?? + resolveStableReviewerAgentType(ownerAgentType ?? undefined, null); + const arbiterAgentType = + normalizeStoredAgentType(task.arbiter_agent_type) ?? + ARBITER_AGENT_TYPE ?? + null; + const ownerServiceId = + task.owner_service_id ?? + resolveRoleServiceShadow('owner', ownerAgentType) ?? + CODEX_MAIN_SERVICE_ID; + const reviewerServiceId = + task.reviewer_service_id ?? + resolveRoleServiceShadow('reviewer', reviewerAgentType) ?? + CODEX_REVIEW_SERVICE_ID; + database .prepare( ` @@ -155,6 +204,8 @@ export function createPairedTaskInDatabase( id, chat_jid, group_folder, + owner_service_id, + reviewer_service_id, owner_agent_type, reviewer_agent_type, arbiter_agent_type, @@ -166,19 +217,22 @@ export function createPairedTaskInDatabase( status, arbiter_verdict, arbiter_requested_at, + completion_reason, created_at, updated_at ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, ) .run( task.id, task.chat_jid, task.group_folder, - task.owner_agent_type ?? null, - task.reviewer_agent_type ?? null, - task.arbiter_agent_type ?? null, + ownerServiceId, + reviewerServiceId, + ownerAgentType, + reviewerAgentType, + arbiterAgentType, task.title, task.source_ref, task.plan_notes, @@ -187,6 +241,7 @@ export function createPairedTaskInDatabase( task.status, task.arbiter_verdict, task.arbiter_requested_at, + task.completion_reason, task.created_at, task.updated_at, ); diff --git a/src/db/schema.ts b/src/db/schema.ts index ca7dc79..a614aca 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -1,29 +1,30 @@ import { Database } from 'bun:sqlite'; -import { normalizeServiceId } from '../config.js'; +import { + ARBITER_AGENT_TYPE, + CLAUDE_SERVICE_ID, + CODEX_MAIN_SERVICE_ID, + CODEX_REVIEW_SERVICE_ID, + OWNER_AGENT_TYPE, + SERVICE_SESSION_SCOPE, + normalizeServiceId, +} from '../config.js'; import { inferAgentTypeFromServiceShadow, resolveRoleServiceShadow, } from '../role-service-shadow.js'; +import { + resolveStablePairedTaskOwnerAgentType, + resolveStableReviewerAgentType, + resolveStableRoomRoleAgentType, +} from './legacy-rebuilds.js'; +import { normalizeStoredAgentType } from './room-registration.js'; import { backfillLegacyServiceSessions, dropLegacyServiceSessionsTable, migrateSessionsTableToCompositePk, } from './sessions.js'; -export interface SchemaMigrationHooks { - backfillMessageSeq(database: Database): void; - backfillStoredRoomSettings(database: Database): void; - backfillChannelOwnerRoleMetadata(database: Database): void; - backfillWorkItemServiceShadows(database: Database): void; - backfillServiceHandoffServiceShadows(database: Database): void; - backfillPairedTaskRoleMetadata(database: Database): void; - rebuildWorkItemsCanonicalSchema(database: Database): void; - rebuildChannelOwnerCanonicalSchema(database: Database): void; - rebuildPairedTasksCanonicalSchema(database: Database): void; - rebuildServiceHandoffsCanonicalSchema(database: Database): void; -} - function getTableColumns(database: Database, tableName: string): string[] { return ( database.prepare(`PRAGMA table_info(${tableName})`).all() as Array<{ @@ -48,6 +49,43 @@ function tryExecMigration(database: Database, sql: string): void { } } +function backfillMessageSeq(database: Database): void { + const rows = database + .prepare( + `SELECT rowid, seq + FROM messages + ORDER BY CASE WHEN seq IS NULL THEN 1 ELSE 0 END, seq, timestamp, rowid`, + ) + .all() as Array<{ rowid: number; seq: number | null }>; + if (rows.length === 0) { + return; + } + + let nextSeq = 1; + const assignSeq = database.prepare( + 'UPDATE messages SET seq = ? WHERE rowid = ? AND seq IS NULL', + ); + const tx = database.transaction(() => { + for (const row of rows) { + if (row.seq === null) { + assignSeq.run(nextSeq, row.rowid); + } + nextSeq = Math.max(nextSeq, (row.seq ?? nextSeq) + 1); + } + }); + tx(); + + const maxSeqRow = database + .prepare('SELECT MAX(seq) AS maxSeq FROM messages') + .get() as { maxSeq: number | null }; + const maxSeq = maxSeqRow.maxSeq ?? 0; + if (maxSeq > 0) { + database + .prepare('INSERT OR IGNORE INTO message_sequence (id) VALUES (?)') + .run(maxSeq); + } +} + interface LegacyExecutionLeaseServiceRow { rowid: number; role: string; @@ -59,13 +97,53 @@ interface LegacyExecutionLeaseServiceRow { arbiter_agent_type?: string | null; } -function normalizePairedAgentType( - agentType: string | null | undefined, -): 'codex' | 'claude-code' | null { - if (agentType === 'codex' || agentType === 'claude-code') { - return agentType; - } - return null; +interface StoredPairedTaskServiceRow { + rowid: number; + chat_jid: string; + group_folder: string; + owner_service_id?: string | null; + reviewer_service_id?: string | null; + owner_agent_type?: string | null; + reviewer_agent_type?: string | null; +} + +interface StoredChannelOwnerLeaseServiceRow { + rowid: number; + chat_jid: string; + owner_service_id?: string | null; + reviewer_service_id?: string | null; + arbiter_service_id?: string | null; + owner_agent_type?: string | null; + reviewer_agent_type?: string | null; + arbiter_agent_type?: string | null; +} + +interface StoredServiceHandoffServiceRow { + id: number; + chat_jid: string; + group_folder: string; + source_service_id?: string | null; + target_service_id?: string | null; + source_role?: string | null; + target_role?: string | null; + source_agent_type?: string | null; + target_agent_type?: string | null; + intended_role?: string | null; +} + +interface StoredWorkItemServiceRow { + id: number; + agent_type?: string | null; + service_id?: string | null; + delivery_role?: string | null; +} + +function normalizePairedRole( + role: string | null | undefined, +): 'owner' | 'reviewer' | 'arbiter' | null { + return role === 'owner' || role === 'reviewer' || role === 'arbiter' + ? role + : null; } function inferLegacyExecutionLeaseServiceId( @@ -73,7 +151,7 @@ function inferLegacyExecutionLeaseServiceId( ): string | null { switch (row.role) { case 'owner': { - const ownerAgentType = normalizePairedAgentType(row.owner_agent_type); + const ownerAgentType = normalizeStoredAgentType(row.owner_agent_type); return ( (row.owner_service_id ? normalizeServiceId(row.owner_service_id) @@ -81,7 +159,7 @@ function inferLegacyExecutionLeaseServiceId( ); } case 'reviewer': { - const reviewerAgentType = normalizePairedAgentType( + const reviewerAgentType = normalizeStoredAgentType( row.reviewer_agent_type, ); return ( @@ -91,7 +169,7 @@ function inferLegacyExecutionLeaseServiceId( ); } case 'arbiter': { - const arbiterAgentType = normalizePairedAgentType(row.arbiter_agent_type); + const arbiterAgentType = normalizeStoredAgentType(row.arbiter_agent_type); return ( (row.arbiter_service_id ? normalizeServiceId(row.arbiter_service_id) @@ -181,14 +259,344 @@ function backfillLegacyExecutionLeaseServiceShadows(database: Database): void { tx(rows); } +function backfillCanonicalPairedTaskServiceIds(database: Database): void { + if ( + !tableHasColumn(database, 'paired_tasks', 'owner_service_id') || + !tableHasColumn(database, 'paired_tasks', 'reviewer_service_id') + ) { + return; + } + + const rows = database + .prepare( + ` + SELECT + rowid, + chat_jid, + group_folder, + owner_service_id, + reviewer_service_id, + owner_agent_type, + reviewer_agent_type + FROM paired_tasks + `, + ) + .all() as StoredPairedTaskServiceRow[]; + if (rows.length === 0) { + return; + } + + const update = database.prepare( + ` + UPDATE paired_tasks + SET owner_service_id = ?, + reviewer_service_id = ? + WHERE rowid = ? + `, + ); + const tx = database.transaction((taskRows: StoredPairedTaskServiceRow[]) => { + for (const row of taskRows) { + const persistedOwnerAgentType = normalizeStoredAgentType( + row.owner_agent_type, + ); + const persistedReviewerAgentType = normalizeStoredAgentType( + row.reviewer_agent_type, + ); + const stableOwnerAgentType = resolveStablePairedTaskOwnerAgentType( + database, + row, + ); + const ownerAgentType = + stableOwnerAgentType ?? + (row.owner_service_id + ? inferAgentTypeFromServiceShadow(row.owner_service_id) + : undefined); + const stableReviewerAgentType = resolveStableReviewerAgentType( + stableOwnerAgentType, + row.reviewer_agent_type ?? null, + ); + const reviewerAgentType = + persistedReviewerAgentType ?? + stableReviewerAgentType ?? + (row.reviewer_service_id + ? inferAgentTypeFromServiceShadow(row.reviewer_service_id) + : null) ?? + resolveStableReviewerAgentType(ownerAgentType, null); + + const ownerServiceId = + (persistedOwnerAgentType + ? resolveRoleServiceShadow('owner', ownerAgentType) + : null) ?? + row.owner_service_id ?? + resolveRoleServiceShadow('owner', ownerAgentType) ?? + CODEX_MAIN_SERVICE_ID; + const reviewerServiceId = + (persistedReviewerAgentType + ? resolveRoleServiceShadow('reviewer', reviewerAgentType) + : null) ?? + row.reviewer_service_id ?? + resolveRoleServiceShadow('reviewer', reviewerAgentType) ?? + CODEX_REVIEW_SERVICE_ID; + + if ( + row.owner_service_id === ownerServiceId && + row.reviewer_service_id === reviewerServiceId + ) { + continue; + } + + update.run(ownerServiceId, reviewerServiceId, row.rowid); + } + }); + + tx(rows); +} + +function backfillCanonicalChannelOwnerServiceIds(database: Database): void { + if (!tableHasColumn(database, 'channel_owner', 'owner_service_id')) { + return; + } + + const rows = database + .prepare( + ` + SELECT + rowid, + chat_jid, + owner_service_id, + reviewer_service_id, + arbiter_service_id, + owner_agent_type, + reviewer_agent_type, + arbiter_agent_type + FROM channel_owner + `, + ) + .all() as StoredChannelOwnerLeaseServiceRow[]; + if (rows.length === 0) { + return; + } + + const update = database.prepare( + ` + UPDATE channel_owner + SET owner_service_id = ?, + reviewer_service_id = ?, + arbiter_service_id = ? + WHERE rowid = ? + `, + ); + const tx = database.transaction( + (leaseRows: StoredChannelOwnerLeaseServiceRow[]) => { + for (const row of leaseRows) { + const persistedOwnerAgentType = normalizeStoredAgentType( + row.owner_agent_type, + ); + const persistedReviewerAgentType = normalizeStoredAgentType( + row.reviewer_agent_type, + ); + const persistedArbiterAgentType = normalizeStoredAgentType( + row.arbiter_agent_type, + ); + const ownerAgentType = + persistedOwnerAgentType ?? + (row.owner_service_id + ? inferAgentTypeFromServiceShadow(row.owner_service_id) + : undefined) ?? + OWNER_AGENT_TYPE; + const reviewerAgentType = + row.reviewer_agent_type == null && row.reviewer_service_id == null + ? null + : (persistedReviewerAgentType ?? + (row.reviewer_service_id + ? inferAgentTypeFromServiceShadow(row.reviewer_service_id) + : null) ?? + resolveStableReviewerAgentType(ownerAgentType, null)); + const arbiterAgentType = + row.arbiter_agent_type == null && row.arbiter_service_id == null + ? null + : (persistedArbiterAgentType ?? + (row.arbiter_service_id + ? inferAgentTypeFromServiceShadow(row.arbiter_service_id) + : undefined) ?? + ARBITER_AGENT_TYPE ?? + null); + + const ownerServiceId = + row.owner_service_id ?? + resolveRoleServiceShadow('owner', ownerAgentType) ?? + CLAUDE_SERVICE_ID; + const reviewerServiceId = + row.reviewer_service_id ?? + (reviewerAgentType == null + ? null + : resolveRoleServiceShadow('reviewer', reviewerAgentType)); + const arbiterServiceId = + row.arbiter_service_id ?? + (arbiterAgentType == null + ? null + : resolveRoleServiceShadow('arbiter', arbiterAgentType)); + + if ( + row.owner_service_id === ownerServiceId && + row.reviewer_service_id === reviewerServiceId && + row.arbiter_service_id === arbiterServiceId + ) { + continue; + } + + update.run( + ownerServiceId, + reviewerServiceId, + arbiterServiceId, + row.rowid, + ); + } + }, + ); + + tx(rows); +} + +function backfillCanonicalServiceHandoffServiceIds(database: Database): void { + if (!tableHasColumn(database, 'service_handoffs', 'source_service_id')) { + return; + } + + const rows = database + .prepare( + ` + SELECT + id, + chat_jid, + group_folder, + source_service_id, + target_service_id, + source_role, + target_role, + source_agent_type, + target_agent_type, + intended_role + FROM service_handoffs + `, + ) + .all() as StoredServiceHandoffServiceRow[]; + if (rows.length === 0) { + return; + } + + const update = database.prepare( + ` + UPDATE service_handoffs + SET source_service_id = ?, + target_service_id = ? + WHERE id = ? + `, + ); + const tx = database.transaction( + (handoffRows: StoredServiceHandoffServiceRow[]) => { + for (const row of handoffRows) { + const sourceRole = + normalizePairedRole(row.source_role) ?? + normalizePairedRole(row.intended_role); + const targetRole = + normalizePairedRole(row.target_role) ?? + normalizePairedRole(row.intended_role); + const sourceAgentType = + normalizeStoredAgentType(row.source_agent_type) ?? + (sourceRole + ? resolveStableRoomRoleAgentType(database, { + chatJid: row.chat_jid, + groupFolder: row.group_folder, + role: sourceRole, + }) + : null); + const targetAgentType = + normalizeStoredAgentType(row.target_agent_type) ?? + (targetRole + ? resolveStableRoomRoleAgentType(database, { + chatJid: row.chat_jid, + groupFolder: row.group_folder, + role: targetRole, + }) + : null) ?? + 'claude-code'; + + const sourceServiceId = + row.source_service_id ?? + (sourceRole != null + ? (resolveRoleServiceShadow(sourceRole, sourceAgentType) ?? + SERVICE_SESSION_SCOPE) + : SERVICE_SESSION_SCOPE); + const targetServiceId = + row.target_service_id ?? + (targetRole != null + ? (resolveRoleServiceShadow(targetRole, targetAgentType) ?? + SERVICE_SESSION_SCOPE) + : SERVICE_SESSION_SCOPE); + + if ( + row.source_service_id === sourceServiceId && + row.target_service_id === targetServiceId + ) { + continue; + } + + update.run(sourceServiceId, targetServiceId, row.id); + } + }, + ); + + tx(rows); +} + +function backfillCanonicalWorkItemServiceIds(database: Database): void { + if (!tableHasColumn(database, 'work_items', 'service_id')) { + return; + } + + const rows = database + .prepare( + `SELECT id, agent_type, service_id, delivery_role + FROM work_items`, + ) + .all() as StoredWorkItemServiceRow[]; + if (rows.length === 0) { + return; + } + + const update = database.prepare( + `UPDATE work_items + SET service_id = ? + WHERE id = ?`, + ); + const tx = database.transaction((workItemRows: StoredWorkItemServiceRow[]) => { + for (const row of workItemRows) { + const agentType = normalizeStoredAgentType(row.agent_type) ?? 'claude-code'; + const deliveryRole = normalizePairedRole(row.delivery_role) ?? 'owner'; + const serviceId = + (row.service_id ? normalizeServiceId(row.service_id) : null) ?? + resolveRoleServiceShadow(deliveryRole, agentType) ?? + SERVICE_SESSION_SCOPE; + + if (row.service_id === serviceId) { + continue; + } + + update.run(serviceId, row.id); + } + }); + + tx(rows); +} + export function applySchemaMigrations( database: Database, args: { assistantName: string; - hooks: SchemaMigrationHooks; }, ): void { - const { assistantName, hooks } = args; + const { assistantName } = args; tryExecMigration( database, @@ -269,6 +677,14 @@ export function applySchemaMigrations( database, `ALTER TABLE service_handoffs ADD COLUMN source_agent_type TEXT`, ); + tryExecMigration( + database, + `ALTER TABLE service_handoffs ADD COLUMN source_service_id TEXT`, + ); + tryExecMigration( + database, + `ALTER TABLE service_handoffs ADD COLUMN target_service_id TEXT`, + ); tryExecMigration( database, @@ -282,6 +698,14 @@ export function applySchemaMigrations( database, `ALTER TABLE paired_tasks ADD COLUMN arbiter_agent_type TEXT`, ); + tryExecMigration( + database, + `ALTER TABLE paired_tasks ADD COLUMN owner_service_id TEXT`, + ); + tryExecMigration( + database, + `ALTER TABLE paired_tasks ADD COLUMN reviewer_service_id TEXT`, + ); tryExecMigration( database, `ALTER TABLE paired_task_execution_leases ADD COLUMN expires_at TEXT`, @@ -311,6 +735,7 @@ export function applySchemaMigrations( database, `ALTER TABLE work_items ADD COLUMN delivery_role TEXT`, ); + tryExecMigration(database, `ALTER TABLE work_items ADD COLUMN service_id TEXT`); database.exec( `UPDATE service_handoffs @@ -333,6 +758,22 @@ export function applySchemaMigrations( WHERE source_role IS NULL`, ); + for (const column of [ + 'owner_service_id', + 'reviewer_service_id', + 'arbiter_service_id', + 'owner_agent_type', + 'reviewer_agent_type', + 'arbiter_agent_type', + ]) { + tryExecMigration(database, `ALTER TABLE channel_owner ADD COLUMN ${column} TEXT`); + } + + backfillCanonicalPairedTaskServiceIds(database); + backfillCanonicalChannelOwnerServiceIds(database); + backfillCanonicalServiceHandoffServiceIds(database); + backfillCanonicalWorkItemServiceIds(database); + database.exec( `UPDATE room_settings SET mode_source = 'explicit' @@ -375,7 +816,7 @@ export function applySchemaMigrations( tryExecMigration(database, `ALTER TABLE messages ADD COLUMN seq INTEGER`); - hooks.backfillMessageSeq(database); + backfillMessageSeq(database); database.exec(` CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_seq ON messages(seq); @@ -385,9 +826,9 @@ export function applySchemaMigrations( database.exec(`DROP INDEX IF EXISTS idx_work_items_open;`); database.exec(` CREATE INDEX IF NOT EXISTS idx_work_items_group_agent - ON work_items(chat_jid, agent_type, delivery_role, status); + ON work_items(chat_jid, agent_type, service_id, delivery_role, status); CREATE UNIQUE INDEX IF NOT EXISTS idx_work_items_open - ON work_items(chat_jid, agent_type, IFNULL(delivery_role, '')) + ON work_items(chat_jid, agent_type, IFNULL(service_id, ''), IFNULL(delivery_role, '')) WHERE status IN ('produced', 'delivery_retry'); `); @@ -516,6 +957,8 @@ export function applySchemaMigrations( 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, @@ -548,11 +991,18 @@ export function applySchemaMigrations( .get() as { sql?: string } | undefined; const ptSql = ptSqlRow?.sql || ''; if (ptSql && !ptSql.includes('arbiter_requested')) { + const pairedTaskCols = getTableColumns(database, 'paired_tasks'); + const selectPairedTaskColumn = (columnName: string): string => + pairedTaskCols.includes(columnName) + ? columnName + : `NULL AS ${columnName}`; database.exec(` CREATE TABLE paired_tasks_new ( id TEXT PRIMARY KEY, chat_jid TEXT NOT NULL, group_folder TEXT NOT NULL, + owner_service_id TEXT, + reviewer_service_id TEXT, owner_agent_type TEXT, reviewer_agent_type TEXT, arbiter_agent_type TEXT, @@ -573,12 +1023,16 @@ export function applySchemaMigrations( CHECK (arbiter_agent_type IN ('claude-code', 'codex') OR arbiter_agent_type IS NULL) ); INSERT INTO paired_tasks_new ( - id, chat_jid, group_folder, owner_agent_type, reviewer_agent_type, + id, chat_jid, group_folder, owner_service_id, reviewer_service_id, + owner_agent_type, reviewer_agent_type, arbiter_agent_type, title, source_ref, plan_notes, review_requested_at, round_trip_count, status, created_at, updated_at ) SELECT - id, chat_jid, group_folder, owner_agent_type, reviewer_agent_type, + id, chat_jid, group_folder, + ${selectPairedTaskColumn('owner_service_id')}, + ${selectPairedTaskColumn('reviewer_service_id')}, + owner_agent_type, reviewer_agent_type, arbiter_agent_type, title, source_ref, plan_notes, review_requested_at, round_trip_count, status, created_at, updated_at FROM paired_tasks; @@ -590,17 +1044,6 @@ export function applySchemaMigrations( } } - for (const column of [ - 'owner_agent_type', - 'reviewer_agent_type', - 'arbiter_agent_type', - ]) { - tryExecMigration( - database, - `ALTER TABLE channel_owner ADD COLUMN ${column} TEXT`, - ); - } - tryExecMigration( database, `ALTER TABLE paired_tasks ADD COLUMN completion_reason TEXT`, @@ -663,6 +1106,8 @@ export function applySchemaMigrations( } migrateSessionsTableToCompositePk(database, 'claude-code'); + backfillLegacyServiceSessions(database, inferAgentTypeFromServiceShadow); + dropLegacyServiceSessionsTable(database); try { database.exec(`ALTER TABLE chats ADD COLUMN channel TEXT`); @@ -683,24 +1128,4 @@ export function applySchemaMigrations( /* columns already exist */ } - hooks.backfillStoredRoomSettings(database); - if (tableHasColumn(database, 'channel_owner', 'owner_service_id')) { - hooks.backfillChannelOwnerRoleMetadata(database); - } - backfillLegacyServiceSessions(database, inferAgentTypeFromServiceShadow); - if (tableHasColumn(database, 'work_items', 'service_id')) { - hooks.backfillWorkItemServiceShadows(database); - } - if (tableHasColumn(database, 'service_handoffs', 'source_service_id')) { - hooks.backfillServiceHandoffServiceShadows(database); - } - if (tableHasColumn(database, 'paired_tasks', 'owner_service_id')) { - hooks.backfillPairedTaskRoleMetadata(database); - } - - hooks.rebuildWorkItemsCanonicalSchema(database); - dropLegacyServiceSessionsTable(database); - hooks.rebuildChannelOwnerCanonicalSchema(database); - hooks.rebuildPairedTasksCanonicalSchema(database); - hooks.rebuildServiceHandoffsCanonicalSchema(database); } diff --git a/src/db/service-handoffs.ts b/src/db/service-handoffs.ts index beea94b..006cf3b 100644 --- a/src/db/service-handoffs.ts +++ b/src/db/service-handoffs.ts @@ -70,6 +70,8 @@ interface StoredServiceHandoffRow extends Omit< | 'source_agent_type' | 'target_agent_type' > { + source_service_id?: string | null; + target_service_id?: string | null; source_agent_type?: string | null; target_agent_type: string; } @@ -103,15 +105,17 @@ function hydrateServiceHandoffRow( source_agent_type: sourceAgentType ?? null, target_agent_type: targetAgentType, source_service_id: - row.source_role != null + row.source_service_id ?? + (row.source_role != null ? (resolveRoleServiceShadow(row.source_role, sourceAgentType) ?? SERVICE_SESSION_SCOPE) - : SERVICE_SESSION_SCOPE, + : SERVICE_SESSION_SCOPE), target_service_id: - row.target_role != null + row.target_service_id ?? + (row.target_role != null ? (resolveRoleServiceShadow(row.target_role, targetAgentType) ?? SERVICE_SESSION_SCOPE) - : SERVICE_SESSION_SCOPE, + : SERVICE_SESSION_SCOPE), }; } @@ -191,12 +195,26 @@ export function createServiceHandoffInDatabase( role: sourceRole, }) : null); + const sourceServiceId = + input.source_service_id ?? + (sourceRole != null + ? (resolveRoleServiceShadow(sourceRole, sourceAgentType) ?? null) + : null) ?? + SERVICE_SESSION_SCOPE; + const targetServiceId = + input.target_service_id ?? + (targetRole != null + ? (resolveRoleServiceShadow(targetRole, input.target_agent_type) ?? null) + : null) ?? + SERVICE_SESSION_SCOPE; database .prepare( `INSERT INTO service_handoffs ( chat_jid, group_folder, + source_service_id, + target_service_id, source_role, source_agent_type, target_role, @@ -206,11 +224,13 @@ export function createServiceHandoffInDatabase( end_seq, reason, intended_role - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) .run( input.chat_jid, input.group_folder, + sourceServiceId, + targetServiceId, sourceRole, sourceAgentType ?? null, targetRole, diff --git a/src/db/sessions.ts b/src/db/sessions.ts index 11bc599..e834992 100644 --- a/src/db/sessions.ts +++ b/src/db/sessions.ts @@ -47,6 +47,59 @@ export function migrateSessionsTableToCompositePk( `); } +export function backfillLegacyServiceSessions( + database: Database, + resolveAgentTypeFromServiceId: ServiceShadowAgentTypeResolver, +): void { + if (!hasLegacyServiceSessionsTable(database)) { + return; + } + + const rows = database + .prepare( + `SELECT group_folder, service_id, session_id + FROM service_sessions`, + ) + .all() as Array<{ + group_folder: string; + service_id: string; + session_id: string; + }>; + + const upsert = database.prepare( + `INSERT OR IGNORE INTO sessions (group_folder, agent_type, session_id) + VALUES (?, ?, ?)`, + ); + + const tx = database.transaction( + ( + sessionRows: Array<{ + group_folder: string; + service_id: string; + session_id: string; + }>, + ) => { + for (const row of sessionRows) { + const agentType = resolveAgentTypeFromServiceId(row.service_id); + if (!agentType) { + continue; + } + upsert.run(row.group_folder, agentType, row.session_id); + } + }, + ); + + tx(rows); +} + +export function dropLegacyServiceSessionsTable(database: Database): void { + if (!hasLegacyServiceSessionsTable(database)) { + return; + } + + database.exec(`DROP TABLE service_sessions`); +} + export function getSessionFromDatabase( database: Database, groupFolder: string, @@ -110,56 +163,3 @@ export function getAllSessionsForAgentTypeFromDatabase( } return result; } - -export function backfillLegacyServiceSessions( - database: Database, - resolveAgentTypeFromServiceId: ServiceShadowAgentTypeResolver, -): void { - if (!hasLegacyServiceSessionsTable(database)) { - return; - } - - const rows = database - .prepare( - `SELECT group_folder, service_id, session_id - FROM service_sessions`, - ) - .all() as Array<{ - group_folder: string; - service_id: string; - session_id: string; - }>; - - const upsert = database.prepare( - `INSERT OR IGNORE INTO sessions (group_folder, agent_type, session_id) - VALUES (?, ?, ?)`, - ); - - const tx = database.transaction( - ( - sessionRows: Array<{ - group_folder: string; - service_id: string; - session_id: string; - }>, - ) => { - for (const row of sessionRows) { - const agentType = resolveAgentTypeFromServiceId(row.service_id); - if (!agentType) { - continue; - } - upsert.run(row.group_folder, agentType, row.session_id); - } - }, - ); - - tx(rows); -} - -export function dropLegacyServiceSessionsTable(database: Database): void { - if (!hasLegacyServiceSessionsTable(database)) { - return; - } - - database.exec(`DROP TABLE service_sessions`); -} diff --git a/src/db/work-items.ts b/src/db/work-items.ts index dca1189..a356c21 100644 --- a/src/db/work-items.ts +++ b/src/db/work-items.ts @@ -1,9 +1,12 @@ import { Database } from 'bun:sqlite'; -import { SERVICE_SESSION_SCOPE } from '../config.js'; -import { inferRoleFromServiceShadow } from '../role-service-shadow.js'; +import { SERVICE_SESSION_SCOPE, normalizeServiceId } from '../config.js'; +import { + inferAgentTypeFromServiceShadow, + inferRoleFromServiceShadow, + resolveRoleServiceShadow, +} from '../role-service-shadow.js'; import { AgentType, PairedRoomRole } from '../types.js'; -import { resolveWorkItemServiceShadow } from './legacy-rebuilds.js'; export interface WorkItem { id: number; @@ -26,15 +29,17 @@ export interface WorkItem { interface StoredWorkItemRow extends Omit< WorkItem, - 'service_id' | 'agent_type' + 'agent_type' | 'service_id' > { agent_type: string; + service_id?: string | null; } export interface CreateProducedWorkItemInput { group_folder: string; chat_jid: string; agent_type?: AgentType; + service_id?: string; delivery_role?: PairedRoomRole | null; start_seq: number | null; end_seq: number | null; @@ -48,38 +53,85 @@ function normalizeStoredAgentType( return undefined; } +function resolveStoredWorkItemServiceId(args: { + agentType: AgentType; + deliveryRole?: PairedRoomRole | null; + serviceId?: string | null; +}): string { + return ( + (args.serviceId ? normalizeServiceId(args.serviceId) : null) ?? + resolveRoleServiceShadow(args.deliveryRole ?? 'owner', args.agentType) ?? + SERVICE_SESSION_SCOPE + ); +} + function hydrateWorkItemRow(row: StoredWorkItemRow): WorkItem { const agentType = normalizeStoredAgentType(row.agent_type) ?? 'claude-code'; return { ...row, agent_type: agentType, - service_id: resolveWorkItemServiceShadow(agentType, row.delivery_role), + service_id: resolveStoredWorkItemServiceId({ + agentType, + deliveryRole: row.delivery_role, + serviceId: row.service_id, + }), }; } +function resolvePreferredWorkItemRole( + serviceId: string | null | undefined, +): PairedRoomRole | null { + const normalizedServiceId = serviceId ? normalizeServiceId(serviceId) : null; + if (!normalizedServiceId) { + return null; + } + + const inferredAgentType = inferAgentTypeFromServiceShadow(normalizedServiceId); + return inferRoleFromServiceShadow(inferredAgentType, normalizedServiceId); +} + export function getOpenWorkItemFromDatabase( database: Database, chatJid: string, agentType: AgentType = 'claude-code', serviceId: string = SERVICE_SESSION_SCOPE, ): WorkItem | undefined { - const preferredRole = inferRoleFromServiceShadow(agentType, serviceId); + const normalizedServiceId = normalizeServiceId(serviceId); + const preferredRole = inferRoleFromServiceShadow( + agentType, + normalizedServiceId, + ); const row = database .prepare( `SELECT * FROM work_items WHERE chat_jid = ? AND agent_type = ? AND status IN ('produced', 'delivery_retry') + AND ( + service_id = ? + OR (? IS NOT NULL AND delivery_role = ?) + OR (? IS NULL AND delivery_role IS NULL) + ) ORDER BY CASE - WHEN ? IS NOT NULL AND delivery_role = ? THEN 0 - WHEN delivery_role IS NULL THEN 1 + WHEN service_id = ? THEN 0 + WHEN ? IS NOT NULL AND delivery_role = ? THEN 1 ELSE 2 END, id ASC LIMIT 1`, ) - .get(chatJid, agentType, preferredRole, preferredRole) as + .get( + chatJid, + agentType, + normalizedServiceId, + preferredRole, + preferredRole, + preferredRole, + normalizedServiceId, + preferredRole, + preferredRole, + ) as | StoredWorkItemRow | undefined; return row ? hydrateWorkItemRow(row) : undefined; @@ -88,17 +140,40 @@ export function getOpenWorkItemFromDatabase( export function getOpenWorkItemForChatFromDatabase( database: Database, chatJid: string, + serviceId: string = SERVICE_SESSION_SCOPE, ): WorkItem | undefined { + const normalizedServiceId = normalizeServiceId(serviceId); + const preferredRole = resolvePreferredWorkItemRole(normalizedServiceId); const row = database .prepare( `SELECT * FROM work_items WHERE chat_jid = ? AND status IN ('produced', 'delivery_retry') - ORDER BY id ASC + AND ( + service_id = ? + OR (? IS NOT NULL AND delivery_role = ?) + OR (? IS NULL AND delivery_role IS NULL) + ) + ORDER BY + CASE + WHEN service_id = ? THEN 0 + WHEN ? IS NOT NULL AND delivery_role = ? THEN 1 + ELSE 2 + END, + id ASC LIMIT 1`, ) - .get(chatJid) as StoredWorkItemRow | undefined; + .get( + chatJid, + normalizedServiceId, + preferredRole, + preferredRole, + preferredRole, + normalizedServiceId, + preferredRole, + preferredRole, + ) as StoredWorkItemRow | undefined; return row ? hydrateWorkItemRow(row) : undefined; } @@ -108,26 +183,33 @@ export function createProducedWorkItemInDatabase( ): WorkItem { const now = new Date().toISOString(); const agentType = input.agent_type || 'claude-code'; + const serviceId = resolveStoredWorkItemServiceId({ + agentType, + deliveryRole: input.delivery_role, + serviceId: input.service_id, + }); database .prepare( `INSERT INTO work_items ( group_folder, chat_jid, agent_type, + service_id, delivery_role, status, start_seq, end_seq, - result_payload, - delivery_attempts, - created_at, - updated_at - ) VALUES (?, ?, ?, ?, 'produced', ?, ?, ?, 0, ?, ?)`, + result_payload, + delivery_attempts, + created_at, + updated_at + ) VALUES (?, ?, ?, ?, ?, 'produced', ?, ?, ?, 0, ?, ?)`, ) .run( input.group_folder, input.chat_jid, agentType, + serviceId, input.delivery_role ?? null, input.start_seq, input.end_seq, diff --git a/src/message-runtime-loop.ts b/src/message-runtime-loop.ts index 56b77c5..cff7b8a 100644 --- a/src/message-runtime-loop.ts +++ b/src/message-runtime-loop.ts @@ -10,6 +10,7 @@ import { advanceLastAgentCursor, getProcessableMessages, } from './message-runtime-rules.js'; +import { SERVICE_SESSION_SCOPE } from './config.js'; import type { ExecuteTurnFn } from './message-runtime-types.js'; import type { ScheduledPairedFollowUpIntentKind } from './paired-follow-up-scheduler.js'; import { findChannel, formatMessages } from './router.js'; @@ -134,7 +135,10 @@ export function recoverPendingMessages(args: { }): void { const registeredGroups = args.getRegisteredGroups(); for (const [chatJid, group] of Object.entries(registeredGroups)) { - const openWorkItem = getOpenWorkItemForChat(chatJid); + const openWorkItem = getOpenWorkItemForChat( + chatJid, + SERVICE_SESSION_SCOPE, + ); if (openWorkItem) { logger.info( { chatJid, group: group.name, workItemId: openWorkItem.id }, diff --git a/src/message-runtime.test.ts b/src/message-runtime.test.ts index 5436745..e098e2c 100644 --- a/src/message-runtime.test.ts +++ b/src/message-runtime.test.ts @@ -1012,7 +1012,7 @@ describe('createMessageRuntime', () => { }); expect(result).toBe(true); - expect(db.getOpenWorkItemForChat).toHaveBeenCalledWith(chatJid); + expect(db.getOpenWorkItemForChat).toHaveBeenCalledWith(chatJid, 'claude'); expect(reviewerChannel.sendMessage).toHaveBeenCalledWith( chatJid, 'fallback reviewer final retry', @@ -1137,7 +1137,10 @@ describe('createMessageRuntime', () => { expect(lastAgentTimestamps[`${chatJid}:reviewer`]).toBe('41'); expect(saveState).toHaveBeenCalled(); expect(db.createProducedWorkItem).toHaveBeenCalledWith( - expect.objectContaining({ delivery_role: 'reviewer' }), + expect.objectContaining({ + delivery_role: 'reviewer', + service_id: 'codex-main', + }), ); expect(reviewerChannel.sendMessage).toHaveBeenCalledWith( chatJid, @@ -1324,6 +1327,7 @@ describe('createMessageRuntime', () => { expect(db.createProducedWorkItem).toHaveBeenCalledWith( expect.objectContaining({ delivery_role: 'reviewer', + service_id: 'codex-main', result_payload: '**DONE**\n\n리뷰 승인', }), ); @@ -1514,7 +1518,10 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead expect(result).toBe(true); expect(agentRunner.runAgentProcess).toHaveBeenCalledTimes(1); expect(db.createProducedWorkItem).toHaveBeenCalledWith( - expect.objectContaining({ delivery_role: 'owner' }), + expect.objectContaining({ + delivery_role: 'owner', + service_id: 'claude', + }), ); expect(channel.sendMessage).toHaveBeenCalledWith(chatJid, '최종 정리 완료'); }); @@ -5078,7 +5085,7 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead runtime.recoverPendingMessages(); - expect(db.getOpenWorkItemForChat).toHaveBeenCalledWith(chatJid); + expect(db.getOpenWorkItemForChat).toHaveBeenCalledWith(chatJid, 'claude'); expect(enqueueMessageCheck).toHaveBeenCalledWith( chatJid, resolveGroupIpcPath(group.folder), diff --git a/src/message-runtime.ts b/src/message-runtime.ts index 0060ea5..da17a40 100644 --- a/src/message-runtime.ts +++ b/src/message-runtime.ts @@ -7,7 +7,7 @@ import { getLastBotFinalMessage, getLatestOpenPairedTaskForChat, } from './db.js'; -import { isSessionCommandSenderAllowed } from './config.js'; +import { isSessionCommandSenderAllowed, SERVICE_SESSION_SCOPE } from './config.js'; import { GroupQueue, GroupRunContext } from './group-queue.js'; import { findChannel, @@ -59,7 +59,11 @@ import { type PairedTask, } from './types.js'; import { createScopedLogger, logger } from './logger.js'; -import { hasReviewerLease } from './service-routing.js'; +import { + getEffectiveChannelLease, + hasReviewerLease, + resolveLeaseServiceId, +} from './service-routing.js'; export { resolveHandoffCursorKey, resolveHandoffRoleOverride, @@ -270,6 +274,10 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { deliverFinalText: async (text) => { try { const persistedDeliveryRole = resolvedDeliveryRole; + const persistedDeliveryServiceId = resolveLeaseServiceId( + getEffectiveChannelLease(chatJid), + persistedDeliveryRole ?? 'owner', + ); if ( (persistedDeliveryRole === 'reviewer' || persistedDeliveryRole === 'arbiter') && @@ -294,6 +302,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { chat_jid: chatJid, agent_type: args.forcedAgentType ?? group.agentType ?? 'claude-code', + service_id: persistedDeliveryServiceId ?? undefined, delivery_role: persistedDeliveryRole, start_seq: startSeq, end_seq: endSeq, @@ -471,7 +480,10 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { // Delivery retries can come from forced fallback runs whose agent_type // differs from the room owner's registered agent type. - const openWorkItem = getOpenWorkItemForChat(chatJid); + const openWorkItem = getOpenWorkItemForChat( + chatJid, + SERVICE_SESSION_SCOPE, + ); const openWorkItemOutcome = await processOpenWorkItemDelivery({ chatJid, runId, diff --git a/src/room-role-context.test.ts b/src/room-role-context.test.ts index 77615cb..717ca3d 100644 --- a/src/room-role-context.test.ts +++ b/src/room-role-context.test.ts @@ -28,7 +28,7 @@ describe('buildRoomRoleContext', () => { }); }); - it('prefers the canonical reviewer shadow over a stale compatibility field', () => { + it('uses the stored reviewer service id from the lease row as-is', () => { expect( buildRoomRoleContext( { @@ -43,13 +43,13 @@ describe('buildRoomRoleContext', () => { reason: null, explicit: true, }, - 'codex-review', + 'stale-reviewer-shadow', ), ).toEqual({ - serviceId: 'codex-review', + serviceId: 'stale-reviewer-shadow', role: 'reviewer', ownerServiceId: 'claude', - reviewerServiceId: 'codex-review', + reviewerServiceId: 'stale-reviewer-shadow', ownerAgentType: 'claude-code', reviewerAgentType: 'codex', failoverOwner: false, diff --git a/src/service-routing.test.ts b/src/service-routing.test.ts index ed0d8c2..eac979d 100644 --- a/src/service-routing.test.ts +++ b/src/service-routing.test.ts @@ -4,6 +4,7 @@ import { _setRegisteredGroupForTests, _setStoredRoomOwnerAgentTypeForTests, _initTestDatabase, + setChannelOwnerLease, setExplicitRoomMode, } from './db.js'; import { @@ -246,7 +247,7 @@ describe('service-routing global failover', () => { }); describe('resolveLeaseServiceId', () => { - it('prefers canonical reviewer shadow over a stale compatibility field', () => { + it('trusts the stored reviewer service id when a lease row already persisted it', () => { expect( resolveLeaseServiceId( { @@ -260,7 +261,7 @@ describe('resolveLeaseServiceId', () => { }, 'reviewer', ), - ).toBe('codex-review'); + ).toBe('stale-reviewer-shadow'); }); it('keeps the explicit owner failover service instead of recomputing the stable shadow', () => { @@ -280,3 +281,30 @@ describe('resolveLeaseServiceId', () => { ).toBe('codex-review'); }); }); + +describe('stored lease ids as SSOT', () => { + it('preserves a stored reviewer service id instead of recomputing it from role metadata', () => { + setChannelOwnerLease({ + chat_jid: 'dc:stored-reviewer-ssot', + owner_service_id: 'claude', + reviewer_service_id: 'stale-reviewer-shadow', + owner_agent_type: 'claude-code', + reviewer_agent_type: 'codex', + activated_at: '2026-04-09T00:00:00.000Z', + reason: 'ssot-test', + }); + refreshChannelOwnerCache(true); + + const lease = getEffectiveChannelLease('dc:stored-reviewer-ssot'); + expect(lease).toMatchObject({ + owner_service_id: 'claude', + reviewer_service_id: 'stale-reviewer-shadow', + owner_agent_type: 'claude-code', + reviewer_agent_type: 'codex', + explicit: true, + }); + expect(resolveLeaseServiceId(lease, 'reviewer')).toBe( + 'stale-reviewer-shadow', + ); + }); +}); diff --git a/src/service-routing.ts b/src/service-routing.ts index 2e599c0..ab91de2 100644 --- a/src/service-routing.ts +++ b/src/service-routing.ts @@ -76,22 +76,24 @@ function normalizeLeaseRow( reviewer_agent_type: reviewerAgentType, arbiter_agent_type: arbiterAgentType, owner_service_id: + (row.owner_service_id ? normalizeServiceId(row.owner_service_id) : null) ?? resolveRoleServiceShadow('owner', ownerAgentType) ?? - normalizeServiceId(row.owner_service_id), - reviewer_service_id: reviewerAgentType - ? (resolveRoleServiceShadow('reviewer', reviewerAgentType) ?? - normalizeServiceId(row.reviewer_service_id!)) - : null, - arbiter_service_id: arbiterAgentType - ? (resolveRoleServiceShadow('arbiter', arbiterAgentType) ?? - (row.arbiter_service_id - ? normalizeServiceId(row.arbiter_service_id) - : null)) - : row.arbiter_service_id + normalizeServiceId(SERVICE_ID), + reviewer_service_id: + (row.reviewer_service_id + ? normalizeServiceId(row.reviewer_service_id) + : null) ?? + (reviewerAgentType + ? resolveRoleServiceShadow('reviewer', reviewerAgentType) + : null), + arbiter_service_id: + (row.arbiter_service_id ? normalizeServiceId(row.arbiter_service_id) - : isArbiterEnabled() - ? ARBITER_SERVICE_ID - : null, + : null) ?? + (arbiterAgentType + ? resolveRoleServiceShadow('arbiter', arbiterAgentType) + : null) ?? + (isArbiterEnabled() ? ARBITER_SERVICE_ID : null), owner_failover_active: false, activated_at: row.activated_at, reason: row.reason, @@ -211,31 +213,31 @@ export function resolveLeaseServiceId( ): string | null { switch (role) { case 'owner': - if (lease.owner_failover_active) { - return normalizeServiceId(lease.owner_service_id); - } return ( + (lease.owner_service_id + ? normalizeServiceId(lease.owner_service_id) + : null) ?? resolveRoleServiceShadow('owner', lease.owner_agent_type) ?? - normalizeServiceId(lease.owner_service_id) + null ); case 'reviewer': - return lease.reviewer_agent_type - ? (resolveRoleServiceShadow('reviewer', lease.reviewer_agent_type) ?? - (lease.reviewer_service_id - ? normalizeServiceId(lease.reviewer_service_id) - : null)) - : lease.reviewer_service_id + return ( + (lease.reviewer_service_id ? normalizeServiceId(lease.reviewer_service_id) - : null; + : null) ?? + (lease.reviewer_agent_type + ? resolveRoleServiceShadow('reviewer', lease.reviewer_agent_type) + : null) + ); case 'arbiter': - return lease.arbiter_agent_type - ? (resolveRoleServiceShadow('arbiter', lease.arbiter_agent_type) ?? - (lease.arbiter_service_id - ? normalizeServiceId(lease.arbiter_service_id) - : null)) - : lease.arbiter_service_id + return ( + (lease.arbiter_service_id ? normalizeServiceId(lease.arbiter_service_id) - : null; + : null) ?? + (lease.arbiter_agent_type + ? resolveRoleServiceShadow('arbiter', lease.arbiter_agent_type) + : null) + ); } }