diff --git a/docs/architecture.md b/docs/architecture.md index 60ef9bd..807b7d6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -51,7 +51,8 @@ Per-room routing uses an explicit assignment model: - `room_mode`: `single` or `tribunal` - `owner_agent_type`: `codex` or `claude-code` - Public room assignment uses `assign_room` -- `registered_groups` remains as a materialized capability/read-model layer +- `registered_groups` is a legacy projection being removed from setup/verify/runtime read paths +- Refactor target and migration sequence live in `docs/legacy-compat-removal-spec.md` Operationally: @@ -90,17 +91,17 @@ restricted environment variables and snapshot checks: ## Key Files -| File | Purpose | -|------|---------| -| `src/index.ts` | Orchestrator: state, message loop, agent invocation | -| `src/agent-runner.ts` | Spawns agent processes, manages env/sessions/skills | -| `src/verification.ts` | Fixed-profile verification execution with snapshot checks | +| File | Purpose | +| ------------------------- | ---------------------------------------------------------- | +| `src/index.ts` | Orchestrator: state, message loop, agent invocation | +| `src/agent-runner.ts` | Spawns agent processes, manages env/sessions/skills | +| `src/verification.ts` | Fixed-profile verification execution with snapshot checks | | `src/channels/discord.ts` | Discord channel (8s typing refresh, Whisper transcription) | -| `src/ipc.ts` | IPC watcher and task processing | -| `src/router.ts` | Message formatting and outbound routing | -| `src/config.ts` | Trigger pattern, paths, intervals | -| `src/task-scheduler.ts` | Runs scheduled tasks | -| `src/db.ts` | SQLite operations | -| `runners/agent-runner/` | Claude Code runner (Agent SDK) | -| `runners/codex-runner/` | Codex runner (SDK, `codex exec` wrapper) | -| `groups/{name}/CLAUDE.md` | Per-group memory (isolated) | +| `src/ipc.ts` | IPC watcher and task processing | +| `src/router.ts` | Message formatting and outbound routing | +| `src/config.ts` | Trigger pattern, paths, intervals | +| `src/task-scheduler.ts` | Runs scheduled tasks | +| `src/db.ts` | SQLite operations | +| `runners/agent-runner/` | Claude Code runner (Agent SDK) | +| `runners/codex-runner/` | Codex runner (SDK, `codex exec` wrapper) | +| `groups/{name}/CLAUDE.md` | Per-group memory (isolated) | diff --git a/docs/legacy-compat-removal-spec.md b/docs/legacy-compat-removal-spec.md new file mode 100644 index 0000000..aa96edb --- /dev/null +++ b/docs/legacy-compat-removal-spec.md @@ -0,0 +1,744 @@ +# EJClaw 레거시 호환 제거 및 구조 정리 Spec + +- 상태: Draft +- 대상: 코어 개발자 / 리팩토링 담당자 +- 기준일: 2026-04-09 +- 기준: 저장소 정적 분석 결과 +- 참고: 현재 환경에는 Bun 런타임이 없어 테스트/실행 검증은 하지 못했고, 이 문서는 코드 구조와 저장소 상태를 기준으로 작성했다. + +## 0. 한 줄 결론 + +이 저장소의 핵심 문제는 **문서상 SSOT는 하나인데, 실제 런타임/설정/검증 경로에는 예전 방식이 계속 살아 있어서 같은 의미가 2~3군데에서 동시에 정의되는 점**이다. 이 브랜치의 목표는 “조금 더 예쁘게 정리”가 아니라, **레거시 호환 경로를 런타임에서 완전히 제거하고, 데이터 모델과 실행 경로를 단일화하는 것**이다. + +--- + +## 1. 문제 정의 + +현재 저장소는 README 상으로는 다음을 선언하고 있다. + +- `room_settings`가 room-level SSOT +- 공개 room assignment 인터페이스는 `assign_room` +- `registered_groups`는 legacy fallback 성격의 materialized layer + +하지만 실제 코드는 아래와 같이 동작한다. + +1. setup 경로는 여전히 `registered_groups`에 직접 쓴다. +2. 앱 시작 시 `registered_groups`를 읽어서 `room_settings`를 다시 복원한다. +3. verify/setup/환경 점검은 `room_settings`와 `registered_groups`를 동시에 본다. +4. 일부 런타임 읽기 로직은 저장된 값이 부족하면 `room_settings`, `registered_groups`, `folder`, `service shadow`를 조합해서 의미를 “추론”한다. +5. 레거시 JSON 파일과 환경변수 alias도 아직 읽는다. + +즉, 현재 버그의 근본 원인은 단순한 코드 스타일 문제가 아니라, **도메인 의미가 여러 저장소/레이어에 중복 저장되고, 부족한 값을 읽기 시점에 복원하는 구조**에 있다. + +--- + +## 2. 이 리팩토링의 목표 + +### 목표 + +1. **단일 진실원(SSOT) 확립** + - room 메타데이터와 실행 설정은 하나의 canonical schema만 사용한다. + +2. **런타임에서 레거시 호환 제거** + - startup, read-path, verify-path 어디에서도 legacy row/json/env alias를 fallback으로 읽지 않는다. + +3. **쓰기 시 canonical, 읽기 시 deterministic** + - 새 데이터는 항상 완전한 canonical 형태로 저장한다. + - 읽을 때 추론/복원/재구성을 하지 않는다. + +4. **책임 분리(SRP)** + - setup, migration, runtime, delivery, scheduler, agent process spawning의 책임을 분리한다. + +5. **작은 단위로 안전하게 삭제** + - “리팩토링하면서 조금씩 살리기”가 아니라, migration → cutover → 삭제 순으로 간다. + +### 비목표 + +1. Tribunal 동작 규칙 자체를 새로 설계하지 않는다. +2. Discord 채널 UX를 바꾸는 작업은 이번 범위의 중심이 아니다. +3. 모델/프로바이더 전략 자체를 갈아엎는 작업은 아니다. +4. 대규모 네이밍 변경을 첫 PR에서 같이 하지 않는다. + - 동작 변경과 rename을 한 PR에 섞지 않는다. + +--- + +## 3. 저장소 감사 결과 + +### 3.1 SSOT 선언과 실제 구현이 다르다 + +문서에서는 `room_settings`를 SSOT라고 적고 있지만, 실제 동작에서는 `registered_groups`가 아직도 쓰기/읽기/복원 경로에 살아 있다. + +| 위치 | 관찰 내용 | 의미 | +| ------------------------------- | ---------------------------------------------------------------------------------- | --------------------------------------------- | +| `README.md:37-43` | `room_settings`가 SSOT라고 문서화 | 설계 의도는 명확함 | +| `setup/register.ts:116-160` | setup 단계가 `registered_groups`를 직접 생성/쓰기 | 공식 setup 경로가 canonical write path를 우회 | +| `src/db.ts:810-878` | `assignRoom()`이 `room_settings`를 쓰면서 동시에 `registered_groups`를 materialize | 하나의 행위를 두 저장소에 반영 | +| `src/db.ts:1000-1016` | startup 시 `registered_groups`를 읽어서 `room_settings`를 다시 sync | 런타임이 legacy projection을 source처럼 사용 | +| `src/db.ts:907-935` | `getAllRegisteredGroups()`가 stored rows + legacy rows를 합쳐서 반환 | read path가 dual-source | +| `setup/verify-state.ts:124-158` | tribunal room 계산 시 `room_settings`와 `registered_groups`를 UNION/fallback | verify도 dual-source | + +### 3.2 setup 경로가 SRP를 심하게 위반한다 + +`setup/register.ts`는 실제로 아래를 한 번에 수행한다. + +1. SQLite 테이블 생성/쓰기 +2. 그룹 폴더 생성 +3. `groups/.../CLAUDE.md` 수정 +4. `.env`의 `ASSISTANT_NAME` 수정 + +특히 `setup/register.ts:170-214`는 채널 등록 단계가 assistant identity까지 바꾸고 있다. 이건 room assignment와 완전히 다른 책임이다. + +결론: **setup/register는 room 등록 도구가 아니라, 여러 전역 설정을 몰래 바꾸는 side-effect command**다. + +### 3.3 startup이 암묵적 마이그레이션을 수행한다 + +`src/db/bootstrap.ts:46-92`는 startup 흐름에서 아래 파일을 읽고 `.migrated`로 rename한다. + +- `router_state.json` +- `sessions.json` +- `registered_groups.json` + +또 `setup/environment.ts:45-58`는 아직도 `registered_groups.json` 존재 여부를 setup 상태 판단에 사용한다. + +결론: **앱 시작 자체가 데이터 정리/마이그레이션 도구 역할을 하고 있어 재현 가능성과 디버깅 가능성을 해친다.** + +### 3.4 읽기 시점 복원 로직이 많다 + +`src/db/legacy-rebuilds.ts:19-96`와 `src/db/paired-state.ts:85-120`는 저장된 값이 부족해도 아래 소스를 뒤져서 owner/reviewer agent type을 복원한다. + +- persisted row +- `room_settings` +- `registered_groups` by jid +- `registered_groups` by folder +- service shadow + +이 구조에서는 row가 불완전해도 시스템이 계속 돌아가므로, 데이터가 어긋난 상태가 초기에 드러나지 않고 런타임 버그로 누적된다. + +결론: **canonical write가 아니라 read-time repair에 의존하고 있다.** + +### 3.5 legacy alias가 계속 살아 있다 + +현재 프로덕션 코드가 여전히 읽는 레거시 alias/호환 키: + +- Discord bot token alias (`src/channels/discord.ts:30-63`) + - `DISCORD_BOT_TOKEN` + - `DISCORD_CLAUDE_BOT_TOKEN` + - `DISCORD_CODEX_BOT_TOKEN` + - `DISCORD_CODEX_MAIN_BOT_TOKEN` + - `DISCORD_REVIEW_BOT_TOKEN` + - `DISCORD_CODEX_REVIEW_BOT_TOKEN` +- session command sender alias (`src/config/load-config.ts:218-223`) + - `SESSION_COMMAND_USER_IDS` +- router state legacy key alias (`src/index.ts:172-203`) + - `last_timestamp` → `last_seq` + - `last_agent_timestamp` → `last_agent_seq` + +결론: **canonical config/state 이름이 있어도 시스템은 구 이름을 받아주기 때문에, 실제 운영 상태를 확신하기 어렵다.** + +### 3.6 God file / high coupling이 심하다 + +정적 집계 기준, 주요 production 파일 크기는 다음과 같다. + +- `src/db.ts` — 1342 LOC +- `src/db/schema.ts` — 1139 LOC +- `src/group-queue.ts` — 1044 LOC +- `src/message-agent-executor.ts` — 910 LOC +- `src/channels/discord.ts` — 892 LOC +- `src/ipc.ts` — 858 LOC +- `src/paired-workspace-manager.ts` — 850 LOC +- `src/message-runtime.ts` — 757 LOC +- `src/index.ts` — 569 LOC +- `src/agent-runner.ts` — 557 LOC + +추가로 함수 길이도 길다. + +- `createMessageRuntime()` — 636 LOC +- `runAgentProcess()` — 494 LOC +- `runTask()` — 367 LOC +- `main()` — 237 LOC + +또한 `db.js` façade를 직접 import하는 파일이 47개다. + +결론: **현재 구조는 개별 버그 수정이 곧 전체 시스템 리스크로 이어지는 결합 형태**다. + +### 3.7 runner 계층 중복이 있다 + +- `runners/agent-runner/src/room-role-context.ts` — 23 LOC +- `runners/codex-runner/src/room-role-context.ts` — 23 LOC (동일) + +`reviewer-runtime.ts`도 역할 정책이라는 같은 도메인을 두 러너가 따로 유지한다. + +결론: **러너별 구현 차이가 아니라 공통 정책 코드의 중복 유지비가 발생하고 있다.** + +### 3.8 테스트도 레거시 호환에 많이 묶여 있다 + +정적 검색상 `legacy|backfill|compat|backward` 키워드가 테스트 코드에서 247개 라인에 등장한다. + +문제는 테스트가 많다는 점이 아니라, **원하는 최종 행동보다 과거 호환 동작을 보호하는 표면적이 너무 넓다**는 점이다. + +--- + +## 4. 핵심 설계 결정 + +### 결정 1. `registered_groups`는 더 이상 runtime source가 아니다 + +최종 목표는 아래 둘 중 하나다. + +1. **권장안:** `registered_groups` 테이블 자체 제거 +2. **차선안:** SQL VIEW 또는 in-memory projection으로만 유지 + - 단, write path 금지 + - source로 사용 금지 + +이번 브랜치의 목표가 “레거시 호환 제거”인 만큼, 최종 상태는 **삭제**를 기본으로 한다. + +### 결정 2. canonical persistence를 아래처럼 재구성한다 + +#### 4.2.1 room-level truth + +`room_settings`를 room-level canonical table로 유지하되, 필요한 필드를 보강한다. + +권장 canonical field: + +- `chat_jid` +- `name` +- `folder` +- `trigger_pattern` +- `requires_trigger` +- `is_main` +- `work_dir` +- `room_mode` +- `owner_agent_type` +- `created_at` +- `updated_at` + +#### 4.2.2 role/runtime override 분리 + +현재 `registered_groups.agent_config`가 사실상 role/runtime override 역할까지 떠안고 있으므로, 아래 신규 테이블을 추가한다. + +`room_role_overrides` + +- `chat_jid` +- `role` (`owner` | `reviewer` | `arbiter`) +- `agent_type` +- `agent_config_json` +- `created_at` +- `updated_at` +- PK: (`chat_jid`, `role`) + +이렇게 하면 + +- room-level 설정은 `room_settings` +- role/runtime override는 `room_role_overrides` +- 실제 실행용 DTO는 코드에서 조립 + +으로 명확히 나뉜다. + +### 결정 3. startup은 마이그레이션을 수행하지 않는다 + +startup이 해도 되는 일: + +- DB open +- schema version check +- required table existence check +- fail fast + +startup이 하면 안 되는 일: + +- JSON state 읽고 rename +- legacy table 보고 canonical row 재구성 +- 불완전 row를 추론으로 보정 + +즉, `src/db/bootstrap.ts`의 JSON migration과 `src/db.ts`의 `syncLegacyRegisteredGroupsIntoStoredRooms()`는 **명시적 migration command**로 옮기고 런타임 경로에서 제거한다. + +### 결정 4. 읽기 시 복원 대신 쓰기 시 canonical complete row를 강제한다 + +새로 생성되는 row는 항상 아래를 완전하게 가져야 한다. + +- paired task: owner/reviewer/arbiter agent type +- paired task: owner/reviewer service id +- channel owner lease: role별 agent/service metadata +- service handoff/work item: target/source role 및 canonical service id + +읽기 시에는 아래를 금지한다. + +- folder 기반 agent type 추론 +- legacy table 기반 fallback +- service id shadow를 보고 agent type 재추론 + +`legacy-rebuilds.ts`는 migration 기간이 끝나면 삭제한다. + +### 결정 5. setup은 application service를 호출해야 한다 + +`setup/register.ts`처럼 setup 코드가 직접 SQL을 치고, 파일 시스템을 수정하고, `.env`를 바꾸는 구조를 금지한다. + +setup은 아래 중 하나만 해야 한다. + +- `assignRoom()` 같은 application service 호출 +- dedicated config command 호출 + +즉, + +- room assignment +- assistant name 변경 +- prompt/CLAUDE.md 템플릿 관리 + +는 서로 다른 command로 분리한다. + +### 결정 6. 네이밍은 “동작 정리 후” 바꾼다 + +현재 `RegisteredGroup`은 이미 room binding/runtime binding DTO에 더 가깝다. 하지만 첫 단계에서 타입 rename까지 섞으면 diff가 너무 커진다. + +권장 순서: + +1. 동작 cutover +2. legacy source 삭제 +3. 그 다음 `RegisteredGroup` → `RoomBinding` rename + +--- + +## 5. 목표 아키텍처 + +### 5.1 도메인 모델 + +#### Room + +room의 room-level truth. + +- chatJid +- name +- folder +- triggerPattern +- requiresTrigger +- isMain +- workDir +- roomMode +- ownerAgentType +- createdAt +- updatedAt + +#### RoomRoleOverride + +room별 role/runtime override. + +- chatJid +- role +- agentType +- agentConfig +- createdAt +- updatedAt + +#### RoomBinding + +실행 시점 DTO. DB에 저장하지 않는다. + +- room +- role +- effectiveAgentType +- effectiveServiceId +- effectiveChannelName +- effectiveAgentConfig +- workDir + +### 5.2 레이어 구조 + +권장 구조: + +```text +src/ + app/ + bootstrap.ts + shutdown.ts + domain/ + rooms/ + room-service.ts + room-binding.ts + room-migration.ts + paired/ + paired-task-service.ts + handoff-service.ts + delivery/ + work-item-service.ts + repositories/ + rooms-repository.ts + room-role-overrides-repository.ts + paired-tasks-repository.ts + messages-repository.ts + router-state-repository.ts + runtime/ + message-loop/ + ingress.ts + turn-coordinator.ts + delivery-coordinator.ts + recovery.ts + agent-process/ + process-runner.ts + output-parser.ts + timeout-controller.ts + run-logger.ts + runners/ + shared/ + room-role-context.ts + reviewer-policy.ts +``` + +핵심은 **composition root**, **domain service**, **repository**, **runtime coordinator**를 분리하는 것이다. + +--- + +## 6. 단계별 실행 계획 + +## Phase 0. 리팩토링 가드레일 추가 + +### 해야 할 일 + +1. `db.ts` 신규 export 금지 +2. 신규 코드에서 legacy key/table 사용 금지 +3. startup 경로에 migration 로직 추가 금지 +4. room assignment는 오직 하나의 application service만 통해서 수행 + +### 산출물 + +- ADR 또는 짧은 architecture note +- lint/grep 기반 CI guard 추가 + +### 통과 기준 + +- 새 PR이 `registered_groups`/legacy env alias를 새로 참조하지 않음 + +--- + +## Phase 1. schema cutover 준비 + +### 해야 할 일 + +1. `room_settings`에 canonical field 부족분 추가 + - 최소: `created_at` +2. `room_role_overrides` 신규 테이블 추가 +3. explicit migration command 구현 + - 기존 `scripts/run-migrations.ts` 체계를 활용 가능 +4. migration report 생성 + - migrated rows + - skipped rows + - conflict rows + +### migration 규칙 + +1. `registered_groups`를 room 단위로 fold +2. room-level 필드 충돌 시 fail fast + - name/folder/requires_trigger/is_main/work_dir conflict는 자동 복구 금지 +3. owner/reviewer runtime row는 `room_role_overrides`로 이동 +4. `registered_groups.json`도 같은 규칙으로 fold +5. 성공 시 legacy backup table/file로 보관 + - 예: `registered_groups_legacy_backup` + - 예: `registered_groups.json.migrated` + +### 통과 기준 + +- migration 명령이 idempotent +- conflict가 있으면 startup이 아니라 migration 단계에서 실패 + +--- + +## Phase 2. write path 단일화 + +### 해야 할 일 + +1. `assignRoom()`이 canonical table만 쓰도록 변경 +2. `materializeRegisteredGroupsForRoom()` 제거 +3. `setup/register.ts` 제거 또는 `setup/assign-room.ts`로 대체 +4. assistant name 변경 로직을 별도 step으로 분리 +5. setup/verify/environment 경로가 canonical table만 보게 수정 + +### 명시적 삭제 대상 + +- `setup/register.ts`의 direct SQL write +- `setup/register.ts`의 `.env` 수정 +- `setup/register.ts`의 `CLAUDE.md` 수정 +- verify output의 legacy field + +### 통과 기준 + +- room 생성/수정 write path가 하나뿐임 +- setup/verify도 그 write path를 공유함 + +--- + +## Phase 3. read path 단일화 + +### 해야 할 일 + +1. `getAllRegisteredGroups()` 제거 또는 `getAllRoomBindings()`로 대체 +2. runtime에서 `room_settings` + `room_role_overrides`만 읽도록 수정 +3. `syncLegacyRegisteredGroupsIntoStoredRooms()` 삭제 +4. `getLegacyRegisteredGroupRows()` / `getLegacyRegisteredGroup()` 삭제 +5. `buildRegisteredGroupFromStoredSettings()`는 남기더라도 source는 canonical table만 사용 + +### 권장 API + +- `getRoom(chatJid)` +- `listRooms()` +- `buildRoomBinding(chatJid, role)` +- `listRoomBindings()` + +### 통과 기준 + +- production code에서 `registered_groups` 참조 0 +- production code에서 `registered_groups.json` 참조 0 + +--- + +## Phase 4. read-time repair 제거 + +### 해야 할 일 + +1. paired task / lease / handoff / work item row migration 수행 +2. row creation 시 canonical service/agent metadata 강제 +3. `legacy-rebuilds.ts` 삭제 +4. `paired-state.ts` hydrate 시 fallback 추론 제거 +5. read path는 incomplete row를 만나면 fail fast + operator action 요구 + +### 통과 기준 + +- `legacy-rebuilds.ts` 삭제 +- `resolveStablePairedTaskOwnerAgentType()` 류의 fallback 제거 +- canonical fields 없는 row가 조용히 복원되지 않음 + +--- + +## Phase 5. 구조 분해 + +### 해야 할 일 + +1. `src/index.ts`를 composition root로 축소 +2. `src/message-runtime.ts` 분해 + - ingress + - pending turn coordinator + - delivery coordinator + - recovery coordinator +3. `src/agent-runner.ts` 분해 + - env builder + - process runner + - output parser + - timeout policy + - run log persistence +4. `src/db.ts` façade 축소 또는 제거 +5. `runners/shared` 추출 + +### 우선 분해 대상 + +1. `src/db.ts` +2. `src/message-runtime.ts` +3. `src/index.ts` +4. `src/agent-runner.ts` +5. `src/ipc.ts` +6. `src/group-queue.ts` + +### 권장 가드레일 + +- 일반 production file 400 LOC 초과 금지 +- 일반 function 80~100 LOC 초과 금지 +- setup/command는 single responsibility 유지 + +> 위 숫자는 절대 규칙이라기보다, 지금처럼 God file로 재응집되는 것을 막기 위한 guardrail이다. + +--- + +## Phase 6. config/state legacy alias 제거 + +### 삭제 대상 + +#### 환경변수 alias + +- `DISCORD_BOT_TOKEN` +- `DISCORD_CLAUDE_BOT_TOKEN` +- `DISCORD_CODEX_BOT_TOKEN` +- `DISCORD_CODEX_MAIN_BOT_TOKEN` +- `DISCORD_REVIEW_BOT_TOKEN` +- `DISCORD_CODEX_REVIEW_BOT_TOKEN` +- `SESSION_COMMAND_USER_IDS` + +#### router state alias + +- `last_timestamp` +- `last_agent_timestamp` + +#### JSON migration source + +- `router_state.json` +- `sessions.json` +- `registered_groups.json` + +### 정책 + +- alias를 더는 읽지 않는다. +- deprecated key가 발견되면 warning이 아니라 startup error를 내고 canonical key명을 안내한다. + +### 통과 기준 + +- production code에서 legacy alias string이 남아 있지 않음 + +--- + +## 7. 추천 PR 분할 + +### PR 1. Schema + explicit migration command + +- `room_role_overrides` 추가 +- migration report 작성 +- legacy backup 전략 도입 + +### PR 2. Room assignment write-path cutover + +- `assignRoom()` canonical-only +- setup/register 제거/대체 +- verify/environment canonical-only + +### PR 3. Runtime read-path cutover + +- `registered_groups` 참조 제거 +- `getAllRoomBindings()` 도입 +- startup sync 제거 + +### PR 4. Paired task canonicalization + +- paired/channel owner/handoff/work item backfill +- `legacy-rebuilds.ts` 삭제 + +### PR 5. Config/state alias 제거 + +- env alias 삭제 +- router state alias 삭제 +- startup JSON migration 삭제 + +### PR 6. 구조 분해 + +- `index.ts`, `message-runtime.ts`, `agent-runner.ts`, `db.ts` 분해 +- `runners/shared` 추출 + +--- + +## 8. 테스트 전략 변경 + +### 기존 문제 + +현재 테스트는 legacy/backfill 호환 보호 비중이 크다. + +### 바꿔야 할 방향 + +1. **runtime 테스트**는 canonical behavior만 검증 +2. **migration 테스트**는 explicit migration command에만 집중 +3. legacy 호환 테스트는 runtime suite에서 제거 +4. startup은 migration을 하지 않는다는 계약을 테스트로 고정 + +### 반드시 추가할 테스트 + +1. canonical room assignment round-trip +2. migration conflict detection +3. incomplete canonical row fail-fast +4. setup/assign-room side-effect isolation +5. startup idempotence +6. paired task hydrate가 fallback 없이 동작함을 보장하는 테스트 + +--- + +## 9. 완료 기준(Definition of Done) + +아래를 모두 만족해야 “레거시 제거 완료”로 본다. + +### 데이터/설정 + +- `registered_groups`가 runtime source가 아님 +- startup이 JSON/legacy row를 읽어 canonical row를 만들지 않음 +- paired/task/handoff/lease row가 canonical metadata를 완전하게 저장함 + +### 코드 + +- `src/db/legacy-rebuilds.ts` 삭제 +- `setup/register.ts` 삭제 또는 room assignment 전용 thin wrapper로 교체 +- production code에서 legacy env alias 참조 0 +- production code에서 legacy state key 참조 0 +- production code에서 `registered_groups` 참조 0 + +### 구조 + +- `src/index.ts`는 bootstrap/composition root 역할만 수행 +- `src/message-runtime.ts`는 coordinator 분해 완료 +- `src/agent-runner.ts`는 process runner 전용 책임으로 축소 +- `db.js` façade 직접 import를 강하게 축소하거나 제거 + +### 운영 + +- migration은 explicit command로만 수행 +- deprecated key가 있으면 startup error로 조기 발견 +- 문서와 코드가 같은 canonical path를 설명함 + +--- + +## 10. 바로 먼저 고쳐야 할 것들 + +전체 리팩토링 전에 가장 빨리 효과가 나는 순서: + +1. `setup/register.ts`에서 `.env` / `CLAUDE.md` 수정 제거 +2. startup의 `syncLegacyRegisteredGroupsIntoStoredRooms()` 제거 준비 +3. explicit migration command 먼저 도입 +4. verify/environment에서 `registered_groups.json`/`registered_groups` fallback 제거 +5. `buildVerifySummary()`의 의미 어긋난 네이밍 정리 + - 현재 `codexConfigured`가 사실상 reviewer bot configured 의미 + - `reviewConfigured`가 arbiter bot configured 의미 + +이 5개만 먼저 해도 “버그가 곳곳에서 새는 느낌”이 크게 줄어든다. + +--- + +## 11. 요약 + +이 저장소는 단순히 코드가 긴 게 문제가 아니다. + +핵심 문제는 다음 세 가지다. + +1. **같은 의미를 여러 저장소가 동시에 들고 있음** +2. **런타임이 과거 데이터를 읽으면서 현재 의미를 추론함** +3. **setup/runtime/migration 책임이 섞여 있음** + +따라서 정리 순서는 반드시 아래여야 한다. + +1. schema와 canonical model을 먼저 고정 +2. explicit migration 제공 +3. write path cutover +4. read path cutover +5. legacy 삭제 +6. 그 다음에 큰 파일 분해와 rename + +즉, 이번 작업의 핵심은 “클린코드 스타일 적용”이 아니라, + +> **호환 경로 삭제 → 진실원 단일화 → 읽기 시 추론 제거 → 책임 분리** + +이다. + +--- + +## 12. 추천 자동 검증 명령 + +아래 검색은 cleanup branch에서 CI guard로 넣는 것을 권장한다. + +```bash +# legacy table / json source +rg -n "registered_groups|registered_groups.json" src setup runners + +# legacy migration-on-startup +rg -n "migrateJsonStateFromFiles|syncLegacyRegisteredGroupsIntoStoredRooms" src setup runners + +# legacy env aliases +rg -n "DISCORD_BOT_TOKEN|DISCORD_CLAUDE_BOT_TOKEN|DISCORD_CODEX_BOT_TOKEN|DISCORD_CODEX_MAIN_BOT_TOKEN|DISCORD_REVIEW_BOT_TOKEN|DISCORD_CODEX_REVIEW_BOT_TOKEN|SESSION_COMMAND_USER_IDS" src setup runners + +# legacy router state aliases +rg -n "last_timestamp|last_agent_timestamp" src setup runners + +# read-time repair helpers +rg -n "legacy-rebuilds|resolveStablePairedTaskOwnerAgentType|resolveStableReviewerAgentType" src setup runners +``` + +최종 상태에서는 위 검색이 아래 중 하나여야 한다. + +- 0건 +- migration command / legacy backup doc / changelog 같은 명시적 예외 파일에만 존재 diff --git a/docs/turn-role-invariant-spec-1.md b/docs/turn-role-invariant-spec-1.md new file mode 100644 index 0000000..efd856a --- /dev/null +++ b/docs/turn-role-invariant-spec-1.md @@ -0,0 +1,309 @@ +# Spec: Preserve the Intended Turn Role Across Auth Failures, Fallbacks, and Service Handoffs + +## Summary + +The invalid token / 401 issue is the **trigger**, but it is **not** the workflow bug. + +The workflow bug is that a logical paired turn can lose its original role (`reviewer` / `arbiter`) during fallback or recovery. A failed reviewer turn must remain a reviewer turn until it either: + +1. completes with a reviewer verdict, +2. escalates to arbiter / human by an explicit reviewer outcome, or +3. is deliberately cancelled. + +It must **not** become an owner execution merely because Claude auth failed, token rotation failed, a fallback handoff was created, or a retry path was entered. + +## Confirmed problems in the current code + +### 1) Service handoff polling ignores `target_service_id` + +In `src/message-runtime-handoffs.ts`, pending handoffs are loaded with `getAllPendingServiceHandoffs()` and then claimed immediately. + +That means **any runtime instance can claim any pending handoff**, even if the handoff was created for another service such as `codex-review`. + +The database layer already has the correct filtered accessor: + +- `getPendingServiceHandoffs(targetServiceId)` in `src/db/service-handoffs.ts` + +but the runtime is not using it. + +This is the clearest reason a reviewer fallback handoff can end up being executed by the wrong service process. + +### 2) The origin run still performs generic paired recovery after delegating to a fallback handoff + +In `src/message-agent-executor.ts`, `maybeHandoffToCodex(...)` creates a service handoff and returns success. + +But the originating paired execution still reaches `asyncFinalize()` in `src/message-agent-executor-paired.ts`, where it can: + +- call `completePairedExecutionContext(...)`, +- preserve the task in `review_ready`, and +- queue a generic paired follow-up after a failed reviewer / arbiter execution. + +So one logical failure path can produce **two continuation mechanisms at once**: + +1. the explicit service handoff, and +2. the normal paired follow-up scheduler. + +That is not a clean model. The turn should continue through **one** mechanism only. + +### 3) Role loss is catastrophic because owner preparation mutates reviewer states + +In `src/paired-execution-context.ts`, the owner branch resets `review_ready` / `in_review` back to `active`. + +So if a reviewer handoff is ever resolved as owner, the system does not merely use the wrong bot; it also mutates the paired task into the wrong workflow state. + +This is why role resolution errors must fail closed, not silently fall back to owner. + +### 4) Some queued paired runs still rely on inferred role instead of an explicit forced role + +`src/message-runtime-flow.ts` and `src/message-runtime-queue.ts` still allow some queued turns to be executed based on inferred task status rather than always carrying an explicit role invariant. + +That is acceptable in the happy path, but it is not robust under retries, stale revisions, or recovery after handoff / error conditions. + +## Desired invariant + +A logical paired turn must carry an explicit immutable role: + +- owner turn stays owner, +- reviewer turn stays reviewer, +- arbiter turn stays arbiter. + +Backend recovery may change: + +- provider (`claude` -> `codex`), +- service runtime (`claude` -> `codex-review`), +- session, +- token, +- retry count, +- process, + +but it must **not** change the logical turn role. + +## Minimal clean fix + +### A. Only the target service may claim a service handoff + +Change `src/message-runtime-handoffs.ts` to use the filtered pending-handoff accessor for the current runtime service instead of the unfiltered accessor. + +Implementation direction: + +- replace `getAllPendingServiceHandoffs()` with `getPendingServiceHandoffs(SERVICE_SESSION_SCOPE)` +- do not let the owner runtime claim a reviewer-targeted handoff +- do not let the reviewer runtime claim an owner-targeted handoff + +This is the smallest and most important correctness fix. + +### B. A delegated fallback handoff must suppress normal paired recovery in the origin run + +When `maybeHandoffToCodex(...)` successfully creates a service handoff, the originating paired run should enter a **delegated** state. + +In that delegated state, the origin run should: + +- stop heartbeat, +- release its lease if needed, +- skip `completePairedExecutionContext(...)`, and +- skip generic paired follow-up enqueue. + +The delegated handoff is now the sole continuation path for that logical turn. + +This avoids double continuation (`service_handoff` + `paired_follow_up`) for the same reviewer failure. + +### C. Never silently default a claimed handoff to owner when role resolution fails + +In `src/message-runtime-handoffs.ts`, if `resolveHandoffRoleOverride(...)` returns `undefined`, the handoff should be failed explicitly. + +It should **not** continue with an implicit owner path. + +Recommended behavior: + +- `failServiceHandoff(handoff.id, 'Cannot resolve intended handoff role')` +- log the row fields (`target_role`, `intended_role`, `reason`, `target_service_id`) +- return without executing the turn + +Failing closed is safer than silently mutating reviewer state into owner state. + +### D. Always pass an explicit role for queued paired turns + +Harden the queued turn path so the runtime always executes the exact intended role. + +Implementation direction: + +- in `src/message-runtime-queue.ts`, use `forcedRole = turnRole` for paired tasks, not only for the mismatch case +- in `src/message-runtime-flow.ts`, include explicit owner / reviewer / arbiter role on pending turns and pass it into `executeTurn(...)` + +This removes unnecessary dependence on mutable task status during recovery. + +## Recommended deeper refactor + +The real long-term fix is to make the **logical turn** a first-class database entity. + +Right now the system derives turn intent from a combination of: + +- task status, +- last persisted turn output role, +- pending reservation rows, +- service handoff rows, +- execution leases. + +That is workable in the happy path, but it is too indirect for failure recovery. + +### Introduce a persistent turn-attempt model + +Create a table (or extend the existing paired reservation / lease model) that stores: + +- `turn_id` +- `task_id` +- `task_updated_at` +- `role` +- `intent_kind` +- `state` (`queued`, `running`, `delegated`, `completed`, `failed`, `cancelled`) +- `executor_service_id` +- `executor_agent_type` +- `attempt_no` +- `created_at`, `updated_at` +- optional `parent_turn_id` or `handoff_from_attempt_id` + +Then enforce these rules: + +1. The scheduler creates a turn record once. +2. Recovery / fallback never creates a new logical turn role. +3. Fallback only updates the executor fields or creates a new attempt of the same turn. +4. The next workflow step is created only when the current turn reaches a terminal state. + +With this design: + +- `reviewer` remains reviewer even if execution moves from Claude to Codex +- token rotation is just another attempt of the same reviewer turn +- handoff cannot accidentally become owner because role is stored on the turn itself + +### Service handoffs should reference the logical turn + +A service handoff row should carry enough identity to prove what it belongs to, at minimum: + +- `paired_task_id` +- `task_updated_at` +- `role` +- `intent_kind` +- ideally `turn_id` + +Without that linkage, a handoff is just “some prompt for this chat”, which is too weak for strong recovery semantics. + +## File-by-file guidance + +### `src/message-runtime-handoffs.ts` + +Required: + +- claim only `getPendingServiceHandoffs(SERVICE_SESSION_SCOPE)` +- fail closed if role cannot be resolved +- add structured logging for `target_service_id`, `target_role`, `intended_role`, and the resolved role + +### `src/message-agent-executor.ts` + +Required: + +- when a fallback handoff is created, mark the current paired run as delegated +- do not let the origin run also go through normal failed-reviewer recovery + +### `src/message-agent-executor-paired.ts` + +Required: + +- add a delegated completion path that releases / stops the current run without mutating the paired task state +- skip executor-side follow-up enqueue for delegated runs + +### `src/message-runtime-queue.ts` + +Required: + +- always pass `forcedRole` for paired turns + +### `src/message-runtime-flow.ts` + +Required: + +- carry explicit role in `PendingPairedTurn` +- pass that role into `executeTurn(...)` + +### `src/paired-execution-context.ts` + +No semantic change is required here for the minimal patch, but this file explains why misrouting is severe: + +- owner preparation resets `review_ready` / `in_review` back to `active` + +That behavior is valid only when the turn is truly owner. + +## Acceptance criteria + +1. A reviewer turn that fails with Claude auth / token errors and falls back to Codex must still be executed as a reviewer turn. +2. The owner runtime must not claim reviewer-targeted service handoffs. +3. Creating a fallback handoff must not also enqueue generic paired reviewer recovery from the same origin run. +4. If the fallback handoff later fails, the requeued turn must still be reviewer, not owner. +5. No path may silently default an unresolved claimed handoff to owner. +6. Reviewer / arbiter recovery must preserve the intended role even when credentials are broken. + +## Regression tests to add + +### 1) Reviewer handoff is only claimed by the target service + +Add a test proving that a `target_service_id = codex-review` handoff is invisible to a `codex-main` runtime poller. + +### 2) Reviewer fallback does not also enqueue generic paired recovery from the origin run + +Simulate: + +- reviewer Claude auth failure +- service handoff creation + +Expect: + +- no executor-side paired follow-up is enqueued from the origin run +- only the service handoff remains as the continuation path + +### 3) Claimed reviewer handoff executes with reviewer role invariant + +Simulate a claimed reviewer handoff and assert that: + +- `forcedRole === reviewer` +- the resolved logical turn role remains reviewer +- owner preparation is not entered + +### 4) Unresolvable handoff role fails closed + +Simulate a malformed handoff row with: + +- no `target_role` +- no `intended_role` +- no reason prefix + +Expect the handoff to fail, not to run as owner. + +### 5) Failed reviewer fallback requeues reviewer again, not owner + +Simulate: + +- reviewer handoff execution fails +- task remains `review_ready` + +Expect the next queued logical turn to remain reviewer. + +## Bottom line + +Yes — this is a workflow bug. + +A token outage should cause: + +- retry, +- rotation, +- handoff, +- or same-role requeue, + +but it should **never** change the logical turn from reviewer to owner unless a real reviewer result explicitly changed the task state. + +The smallest clean patch is: + +1. filter handoff claiming by `target_service_id`, +2. treat fallback handoff as delegated continuation (not as a normal failed turn), +3. fail closed when handoff role cannot be resolved, and +4. always pass explicit `forcedRole` for queued paired turns. + +The real architectural fix is to store the logical turn explicitly and treat fallback / retry as attempts of that same turn instead of inferring intent from task status during recovery. diff --git a/setup/environment.test.ts b/setup/environment.test.ts index bfd82dd..320f6d0 100644 --- a/setup/environment.test.ts +++ b/setup/environment.test.ts @@ -1,7 +1,10 @@ -import { describe, it, expect, beforeEach } from 'vitest'; import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { Database } from 'bun:sqlite'; +import { detectAssignedRooms, run as runEnvironment } from './environment.js'; /** * Tests for the environment check step. @@ -17,58 +20,174 @@ describe('environment detection', () => { }); }); -describe('registered groups DB query', () => { - let db: Database; +describe('assigned rooms detection', () => { + const tempDirs: string[] = []; - beforeEach(() => { - db = new Database(':memory:'); - db.exec(`CREATE TABLE IF NOT EXISTS registered_groups ( - jid TEXT PRIMARY KEY, - name TEXT NOT NULL, - folder TEXT NOT NULL UNIQUE, - trigger_pattern TEXT NOT NULL, - added_at TEXT NOT NULL, - agent_config TEXT, - requires_trigger INTEGER DEFAULT 1 - )`); + afterEach(() => { + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } }); - it('returns 0 for empty table', () => { - const row = db - .prepare('SELECT COUNT(*) as count FROM registered_groups') - .get() as { count: number }; - expect(row.count).toBe(0); + it('returns false when the database does not exist', () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-env-')); + tempDirs.push(tempDir); + + expect( + detectAssignedRooms({ dbPath: path.join(tempDir, 'messages.db') }), + ).toBe(false); }); - it('returns correct count after inserts', () => { + it('returns false for an empty room_settings table', () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-env-')); + tempDirs.push(tempDir); + const dbPath = path.join(tempDir, 'messages.db'); + const db = new Database(dbPath); + db.exec(`CREATE TABLE room_settings (chat_jid TEXT PRIMARY KEY)`); + db.close(); + + expect(detectAssignedRooms({ dbPath })).toBe(false); + }); + + it('returns true when canonical room assignments exist', () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-env-')); + tempDirs.push(tempDir); + const dbPath = path.join(tempDir, 'messages.db'); + const db = new Database(dbPath); + db.exec(` + CREATE TABLE room_settings ( + chat_jid TEXT PRIMARY KEY, + room_mode TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + `); db.prepare( - `INSERT INTO registered_groups (jid, name, folder, trigger_pattern, added_at, requires_trigger) - VALUES (?, ?, ?, ?, ?, ?)`, - ).run( - '123@g.us', - 'Group 1', - 'group-1', - '@Andy', - '2024-01-01T00:00:00.000Z', - 1, + `INSERT INTO room_settings (chat_jid, room_mode, updated_at) + VALUES (?, ?, ?)`, + ).run('dc:room-1', 'single', '2024-01-01T00:00:00.000Z'); + db.close(); + + expect(detectAssignedRooms({ dbPath })).toBe(true); + }); + + it('returns false when only legacy registered_groups rows exist', () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-env-')); + tempDirs.push(tempDir); + const dbPath = path.join(tempDir, 'messages.db'); + const db = new Database(dbPath); + db.exec(` + CREATE TABLE registered_groups ( + jid TEXT PRIMARY KEY, + agent_type TEXT + ) + `); + db.exec(` + INSERT INTO registered_groups (jid, agent_type) + VALUES ('dc:legacy-room', 'claude-code') + `); + db.close(); + + expect(detectAssignedRooms({ dbPath })).toBe(false); + }); +}); + +describe('environment step legacy-room handling', () => { + const originalCwd = process.cwd(); + const tempDirs: string[] = []; + + afterEach(() => { + process.chdir(originalCwd); + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } + vi.restoreAllMocks(); + }); + + it('exits with failure when legacy-only room registrations need migration', async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-env-run-')); + tempDirs.push(tempDir); + process.chdir(tempDir); + + fs.mkdirSync(path.join(tempDir, 'store'), { recursive: true }); + const db = new Database(path.join(tempDir, 'store', 'messages.db')); + db.exec(` + CREATE TABLE registered_groups ( + jid TEXT PRIMARY KEY, + agent_type TEXT + ) + `); + db.exec(` + INSERT INTO registered_groups (jid, agent_type) + VALUES ('dc:legacy-room', 'claude-code') + `); + db.close(); + + const exitSpy = vi + .spyOn(process, 'exit') + .mockImplementation((code?: string | number | null | undefined) => { + throw new Error(`process.exit:${code}`); + }); + + await expect(runEnvironment([])).rejects.toThrow('process.exit:1'); + exitSpy.mockRestore(); + }); + + it('exits with failure when canonical rooms exist but pending legacy registrations remain', async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-env-run-')); + tempDirs.push(tempDir); + process.chdir(tempDir); + + fs.mkdirSync(path.join(tempDir, 'store'), { recursive: true }); + const db = new Database(path.join(tempDir, 'store', 'messages.db')); + db.exec(` + CREATE TABLE room_settings ( + chat_jid TEXT PRIMARY KEY, + room_mode TEXT NOT NULL + ); + CREATE TABLE registered_groups ( + jid TEXT PRIMARY KEY, + agent_type TEXT + ) + `); + db.exec(` + INSERT INTO room_settings (chat_jid, room_mode) + VALUES ('dc:canonical-room', 'single') + `); + db.exec(` + INSERT INTO registered_groups (jid, agent_type) + VALUES ('dc:legacy-room', 'claude-code') + `); + db.close(); + + const exitSpy = vi + .spyOn(process, 'exit') + .mockImplementation((code?: string | number | null | undefined) => { + throw new Error(`process.exit:${code}`); + }); + + await expect(runEnvironment([])).rejects.toThrow('process.exit:1'); + exitSpy.mockRestore(); + }); + + it('exits with failure when legacy json state files need explicit migration', async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-env-run-')); + tempDirs.push(tempDir); + process.chdir(tempDir); + + fs.mkdirSync(path.join(tempDir, 'data'), { recursive: true }); + fs.writeFileSync( + path.join(tempDir, 'data', 'router_state.json'), + JSON.stringify({ last_timestamp: '1234' }), ); - db.prepare( - `INSERT INTO registered_groups (jid, name, folder, trigger_pattern, added_at, requires_trigger) - VALUES (?, ?, ?, ?, ?, ?)`, - ).run( - '456@g.us', - 'Group 2', - 'group-2', - '@Andy', - '2024-01-01T00:00:00.000Z', - 1, - ); + const exitSpy = vi + .spyOn(process, 'exit') + .mockImplementation((code?: string | number | null | undefined) => { + throw new Error(`process.exit:${code}`); + }); - const row = db - .prepare('SELECT COUNT(*) as count FROM registered_groups') - .get() as { count: number }; - expect(row.count).toBe(2); + await expect(runEnvironment([])).rejects.toThrow('process.exit:1'); + exitSpy.mockRestore(); }); }); diff --git a/setup/environment.ts b/setup/environment.ts index 15a68fc..b088d72 100644 --- a/setup/environment.ts +++ b/setup/environment.ts @@ -5,9 +5,6 @@ import fs from 'fs'; import path from 'path'; -import { Database } from 'bun:sqlite'; - -import { STORE_DIR } from '../src/config.js'; import { logger } from '../src/logger.js'; import { canUseLinuxBubblewrapReadonlySandbox, @@ -17,8 +14,18 @@ import { isHeadless, isWSL, } from './platform.js'; +import { + detectRoomRegistrationState, + getLegacyMigrationGuidance, +} from './room-registration-state.js'; import { emitStatus } from './status.js'; +export function detectAssignedRooms( + options?: Parameters[0], +): boolean { + return detectRoomRegistrationState(options).assignedRooms > 0; +} + export async function run(_args: string[]): Promise { const projectRoot = process.cwd(); @@ -30,9 +37,7 @@ export async function run(_args: string[]): Promise { const hasBubblewrap = platform === 'linux' && commandExists('bwrap'); const hasSocat = platform === 'linux' && commandExists('socat'); const apparmorRestrictUnprivilegedUserns = - platform === 'linux' - ? getAppArmorRestrictUnprivilegedUsernsValue() - : null; + platform === 'linux' ? getAppArmorRestrictUnprivilegedUsernsValue() : null; const hasBubblewrapReadonlySandboxCapability = platform === 'linux' && canUseLinuxBubblewrapReadonlySandbox(); @@ -42,26 +47,15 @@ export async function run(_args: string[]): Promise { const authDir = path.join(projectRoot, 'store', 'auth'); const hasAuth = fs.existsSync(authDir) && fs.readdirSync(authDir).length > 0; - let hasRegisteredGroups = false; - // Check JSON file first (pre-migration) - if (fs.existsSync(path.join(projectRoot, 'data', 'registered_groups.json'))) { - hasRegisteredGroups = true; - } else { - // Check SQLite directly using better-sqlite3 (no sqlite3 CLI needed) - const dbPath = path.join(STORE_DIR, 'messages.db'); - if (fs.existsSync(dbPath)) { - try { - const db = new Database(dbPath, { readonly: true }); - const row = db - .prepare('SELECT COUNT(*) as count FROM registered_groups') - .get() as { count: number }; - if (row.count > 0) hasRegisteredGroups = true; - db.close(); - } catch { - // Table might not exist yet - } - } - } + const roomState = detectRoomRegistrationState({ + projectRoot, + dbPath: path.join(projectRoot, 'store', 'messages.db'), + }); + const legacyMigrationGuidance = getLegacyMigrationGuidance( + roomState, + 'setup', + ); + const status = legacyMigrationGuidance ? 'failed' : 'success'; logger.info( { @@ -69,7 +63,13 @@ export async function run(_args: string[]): Promise { wsl, hasEnv, hasAuth, - hasRegisteredGroups, + assignedRooms: roomState.assignedRooms, + legacyRegisteredGroupRows: roomState.legacyRegisteredGroupRows, + hasLegacyRegisteredGroupsJson: roomState.hasLegacyRegisteredGroupsJson, + legacyRoomMigrationRequired: roomState.legacyRoomMigrationRequired, + pendingLegacyJsonStateFiles: roomState.pendingLegacyJsonStateFiles, + legacyJsonStateMigrationRequired: + roomState.legacyJsonStateMigrationRequired, hasBubblewrap, hasSocat, apparmorRestrictUnprivilegedUserns, @@ -84,14 +84,30 @@ export async function run(_args: string[]): Promise { IS_HEADLESS: headless, HAS_ENV: hasEnv, HAS_AUTH: hasAuth, - HAS_REGISTERED_GROUPS: hasRegisteredGroups, + HAS_ASSIGNED_ROOMS: roomState.assignedRooms > 0, + ASSIGNED_ROOMS: roomState.assignedRooms, + LEGACY_REGISTERED_GROUP_ROWS: roomState.legacyRegisteredGroupRows, + HAS_LEGACY_REGISTERED_GROUPS_JSON: roomState.hasLegacyRegisteredGroupsJson, + LEGACY_ROOM_MIGRATION_REQUIRED: roomState.legacyRoomMigrationRequired, + PENDING_LEGACY_JSON_STATE_FILES: + roomState.pendingLegacyJsonStateFiles.join(','), + LEGACY_JSON_STATE_MIGRATION_REQUIRED: + roomState.legacyJsonStateMigrationRequired, HAS_BWRAP: hasBubblewrap, HAS_SOCAT: hasSocat, APPARMOR_RESTRICT_UNPRIVILEGED_USERNS: apparmorRestrictUnprivilegedUserns ?? 'n/a', HAS_BWRAP_READONLY_SANDBOX_CAPABILITY: hasBubblewrapReadonlySandboxCapability, - STATUS: 'success', + ...(legacyMigrationGuidance + ? { + ERROR: legacyMigrationGuidance.error, + NEXT_STEP: legacyMigrationGuidance.nextStep, + } + : {}), + STATUS: status, LOG: 'logs/setup.log', }); + + if (status === 'failed') process.exit(1); } diff --git a/setup/index.ts b/setup/index.ts index 2d35be3..6fd031e 100644 --- a/setup/index.ts +++ b/setup/index.ts @@ -13,6 +13,8 @@ const STEPS: Record< runners: () => import('./runners.js'), groups: () => import('./groups.js'), register: () => import('./register.js'), + 'migrate-json-state': () => import('./migrate-json-state.js'), + 'migrate-room-registrations': () => import('./migrate-room-registrations.js'), 'restart-stack': () => import('./restart-stack.js'), service: () => import('./service.js'), verify: () => import('./verify.js'), diff --git a/setup/migrate-json-state.test.ts b/setup/migrate-json-state.test.ts new file mode 100644 index 0000000..ba0d14e --- /dev/null +++ b/setup/migrate-json-state.test.ts @@ -0,0 +1,152 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { Database } from 'bun:sqlite'; + +describe('migrate json state step', () => { + const tempRoots: string[] = []; + + afterEach(() => { + vi.resetModules(); + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + for (const root of tempRoots.splice(0)) { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it('does not migrate legacy json state during startup and leaves files in place', async () => { + const tempRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'ejclaw-migrate-json-init-'), + ); + tempRoots.push(tempRoot); + const storeDir = path.join(tempRoot, 'store'); + const dataDir = path.join(tempRoot, 'data'); + fs.mkdirSync(storeDir, { recursive: true }); + fs.mkdirSync(dataDir, { recursive: true }); + vi.stubEnv('EJCLAW_STORE_DIR', storeDir); + vi.stubEnv('EJCLAW_DATA_DIR', dataDir); + + fs.writeFileSync( + path.join(dataDir, 'router_state.json'), + JSON.stringify({ last_timestamp: '1234' }), + ); + fs.writeFileSync( + path.join(dataDir, 'sessions.json'), + JSON.stringify({ 'group-a': 'session-123' }), + ); + + const { _initTestDatabase, initDatabase } = await import('../src/db.js'); + + expect(() => initDatabase()).toThrow( + /Legacy JSON state migration required before startup/, + ); + expect(fs.existsSync(path.join(dataDir, 'router_state.json'))).toBe(true); + expect(fs.existsSync(path.join(dataDir, 'sessions.json'))).toBe(true); + expect( + fs.existsSync(path.join(dataDir, 'router_state.json.migrated')), + ).toBe(false); + expect(fs.existsSync(path.join(dataDir, 'sessions.json.migrated'))).toBe( + false, + ); + + _initTestDatabase(); + }); + + it('migrates router_state.json and sessions.json only via explicit setup step', async () => { + const tempRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'ejclaw-migrate-json-step-'), + ); + tempRoots.push(tempRoot); + const storeDir = path.join(tempRoot, 'store'); + const dataDir = path.join(tempRoot, 'data'); + fs.mkdirSync(storeDir, { recursive: true }); + fs.mkdirSync(dataDir, { recursive: true }); + vi.stubEnv('EJCLAW_STORE_DIR', storeDir); + vi.stubEnv('EJCLAW_DATA_DIR', dataDir); + + fs.writeFileSync( + path.join(dataDir, 'router_state.json'), + JSON.stringify({ + last_timestamp: '1234', + last_agent_timestamp: { + 'dc:test-room': '5678', + }, + }), + ); + fs.writeFileSync( + path.join(dataDir, 'sessions.json'), + JSON.stringify({ + 'group-a': 'session-123', + 'group-b': 'session-456', + }), + ); + + const emitStatusMock = vi.fn(); + vi.doMock('./status.js', () => ({ + emitStatus: emitStatusMock, + })); + + const { run } = await import('./migrate-json-state.js'); + + await run([]); + + const db = new Database(path.join(storeDir, 'messages.db'), { + readonly: true, + }); + expect( + db.prepare('SELECT key, value FROM router_state ORDER BY key').all(), + ).toEqual([ + { + key: 'last_agent_seq', + value: '{"dc:test-room":"5678"}', + }, + { + key: 'last_seq', + value: '1234', + }, + ]); + expect( + db + .prepare( + `SELECT group_folder, agent_type, session_id + FROM sessions + ORDER BY group_folder`, + ) + .all(), + ).toEqual([ + { + group_folder: 'group-a', + agent_type: 'claude-code', + session_id: 'session-123', + }, + { + group_folder: 'group-b', + agent_type: 'claude-code', + session_id: 'session-456', + }, + ]); + db.close(); + + expect(fs.existsSync(path.join(dataDir, 'router_state.json'))).toBe(false); + expect(fs.existsSync(path.join(dataDir, 'sessions.json'))).toBe(false); + expect( + fs.existsSync(path.join(dataDir, 'router_state.json.migrated')), + ).toBe(true); + expect(fs.existsSync(path.join(dataDir, 'sessions.json.migrated'))).toBe( + true, + ); + expect(emitStatusMock).toHaveBeenLastCalledWith( + 'MIGRATE_JSON_STATE', + expect.objectContaining({ + MIGRATED_ROUTER_STATE_KEYS: 2, + MIGRATED_SESSIONS: 2, + RENAMED_ROUTER_STATE_JSON: true, + RENAMED_SESSIONS_JSON: true, + STATUS: 'success', + }), + ); + }); +}); diff --git a/setup/migrate-json-state.ts b/setup/migrate-json-state.ts new file mode 100644 index 0000000..0ff381f --- /dev/null +++ b/setup/migrate-json-state.ts @@ -0,0 +1,116 @@ +import fs from 'fs'; +import path from 'path'; + +import { DATA_DIR, STORE_DIR } from '../src/config.js'; +import { + initializeDatabaseSchema, + openPersistentDatabase, +} from '../src/db/bootstrap.js'; +import { setRouterStateInDatabase } from '../src/db/router-state.js'; +import { setSessionInDatabase } from '../src/db/sessions.js'; +import { readJsonFile } from '../src/utils.js'; +import { emitStatus } from './status.js'; + +interface JsonMigrationReport { + migratedRouterStateKeys: number; + migratedSessions: number; + renamedRouterStateJson: boolean; + renamedSessionsJson: boolean; +} + +function renameMigratedFile(filePath: string): void { + fs.renameSync(filePath, `${filePath}.migrated`); +} + +function migrateRouterStateJson( + database: ReturnType, +): { + migratedKeys: number; + renamed: boolean; +} { + const routerStatePath = path.join(DATA_DIR, 'router_state.json'); + const routerState = readJsonFile(routerStatePath) as { + last_seq?: string; + last_timestamp?: string; + last_agent_seq?: Record; + last_agent_timestamp?: Record; + } | null; + if (!routerState) { + return { migratedKeys: 0, renamed: false }; + } + + const lastSeq = routerState.last_seq ?? routerState.last_timestamp; + const lastAgentSeq = + routerState.last_agent_seq ?? routerState.last_agent_timestamp; + let migratedKeys = 0; + if (lastSeq) { + setRouterStateInDatabase(database, 'last_seq', lastSeq); + migratedKeys += 1; + } + if (lastAgentSeq) { + setRouterStateInDatabase( + database, + 'last_agent_seq', + JSON.stringify(lastAgentSeq), + ); + migratedKeys += 1; + } + renameMigratedFile(routerStatePath); + return { migratedKeys, renamed: true }; +} + +function migrateSessionsJson( + database: ReturnType, +): { + migratedSessions: number; + renamed: boolean; +} { + const sessionsPath = path.join(DATA_DIR, 'sessions.json'); + const sessions = readJsonFile(sessionsPath) as Record | null; + if (!sessions) { + return { migratedSessions: 0, renamed: false }; + } + + for (const [groupFolder, sessionId] of Object.entries(sessions)) { + setSessionInDatabase(database, groupFolder, 'claude-code', sessionId); + } + renameMigratedFile(sessionsPath); + return { + migratedSessions: Object.keys(sessions).length, + renamed: true, + }; +} + +export async function run(_args: string[]): Promise { + fs.mkdirSync(STORE_DIR, { recursive: true }); + const database = openPersistentDatabase(); + initializeDatabaseSchema(database); + + const report: JsonMigrationReport = { + migratedRouterStateKeys: 0, + migratedSessions: 0, + renamedRouterStateJson: false, + renamedSessionsJson: false, + }; + + database.transaction(() => { + const routerStateResult = migrateRouterStateJson(database); + report.migratedRouterStateKeys = routerStateResult.migratedKeys; + report.renamedRouterStateJson = routerStateResult.renamed; + + const sessionsResult = migrateSessionsJson(database); + report.migratedSessions = sessionsResult.migratedSessions; + report.renamedSessionsJson = sessionsResult.renamed; + })(); + + database.close(); + + emitStatus('MIGRATE_JSON_STATE', { + MIGRATED_ROUTER_STATE_KEYS: report.migratedRouterStateKeys, + MIGRATED_SESSIONS: report.migratedSessions, + RENAMED_ROUTER_STATE_JSON: report.renamedRouterStateJson, + RENAMED_SESSIONS_JSON: report.renamedSessionsJson, + STATUS: 'success', + LOG: 'logs/setup.log', + }); +} diff --git a/setup/migrate-room-registrations.test.ts b/setup/migrate-room-registrations.test.ts new file mode 100644 index 0000000..fc73dbf --- /dev/null +++ b/setup/migrate-room-registrations.test.ts @@ -0,0 +1,726 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { Database } from 'bun:sqlite'; + +describe('migrate room registrations step', () => { + const tempRoots: string[] = []; + + afterEach(() => { + vi.resetModules(); + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + for (const root of tempRoots.splice(0)) { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it('migrates pending legacy rows and legacy json into canonical room tables idempotently', async () => { + const tempRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'ejclaw-migrate-rooms-'), + ); + tempRoots.push(tempRoot); + const storeDir = path.join(tempRoot, 'store'); + const dataDir = path.join(tempRoot, 'data'); + fs.mkdirSync(storeDir, { recursive: true }); + fs.mkdirSync(dataDir, { recursive: true }); + vi.stubEnv('EJCLAW_STORE_DIR', storeDir); + vi.stubEnv('EJCLAW_DATA_DIR', dataDir); + + const dbPath = path.join(storeDir, '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) + ) + `); + legacyDb + .prepare( + `INSERT INTO registered_groups ( + jid, + name, + folder, + trigger_pattern, + added_at, + agent_config, + requires_trigger, + is_main, + agent_type, + work_dir + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + 'dc:legacy-room', + 'Legacy Room', + 'legacy-room', + '@Claude', + '2024-01-01T00:00:00.000Z', + '{"provider":"anthropic"}', + 1, + 0, + 'claude-code', + null, + ); + legacyDb + .prepare( + `INSERT INTO registered_groups ( + jid, + name, + folder, + trigger_pattern, + added_at, + agent_config, + requires_trigger, + is_main, + agent_type, + work_dir + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + 'dc:legacy-room', + 'Legacy Room', + 'legacy-room', + '@Codex', + '2024-01-02T00:00:00.000Z', + '{"provider":"openai"}', + 1, + 0, + 'codex', + null, + ); + legacyDb.close(); + + fs.writeFileSync( + path.join(dataDir, 'registered_groups.json'), + JSON.stringify({ + 'dc:json-room': { + name: 'JSON Room', + folder: 'json-room', + trigger: '@Andy', + added_at: '2024-02-01T00:00:00.000Z', + agentType: 'claude-code', + requiresTrigger: true, + isMain: false, + }, + }), + ); + + const emitStatusMock = vi.fn(); + vi.doMock('./status.js', () => ({ + emitStatus: emitStatusMock, + })); + vi.doMock('../src/logger.js', () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + }, + })); + + const { run } = await import('./migrate-room-registrations.js'); + + await run([]); + + const migratedDb = new Database(dbPath, { readonly: true }); + expect( + migratedDb + .prepare( + `SELECT chat_jid, room_mode, mode_source, owner_agent_type + FROM room_settings + ORDER BY chat_jid`, + ) + .all(), + ).toEqual([ + { + chat_jid: 'dc:json-room', + room_mode: 'single', + mode_source: 'inferred', + owner_agent_type: 'claude-code', + }, + { + chat_jid: 'dc:legacy-room', + room_mode: 'tribunal', + mode_source: 'inferred', + owner_agent_type: 'codex', + }, + ]); + expect( + migratedDb + .prepare( + `SELECT chat_jid, role, agent_type + FROM room_role_overrides + ORDER BY chat_jid, role`, + ) + .all(), + ).toEqual([ + { + chat_jid: 'dc:json-room', + role: 'owner', + agent_type: 'claude-code', + }, + { + chat_jid: 'dc:legacy-room', + role: 'owner', + agent_type: 'codex', + }, + { + chat_jid: 'dc:legacy-room', + role: 'reviewer', + agent_type: 'claude-code', + }, + ]); + expect( + migratedDb + .prepare( + `SELECT jid, agent_type + FROM registered_groups_legacy_backup + ORDER BY jid, agent_type`, + ) + .all(), + ).toEqual([ + { + jid: 'dc:legacy-room', + agent_type: 'claude-code', + }, + { + jid: 'dc:legacy-room', + agent_type: 'codex', + }, + ]); + migratedDb.close(); + + expect(fs.existsSync(path.join(dataDir, 'registered_groups.json'))).toBe( + false, + ); + expect( + fs.existsSync(path.join(dataDir, 'registered_groups.json.migrated')), + ).toBe(true); + expect(emitStatusMock).toHaveBeenLastCalledWith( + 'MIGRATE_ROOM_REGISTRATIONS', + expect.objectContaining({ + MIGRATED_ROOMS: 2, + MIGRATED_ROLE_OVERRIDES: 3, + SKIPPED_ROOMS: 0, + MIGRATED_JSON_ROOMS: 1, + SKIPPED_JSON_ROOMS: 0, + BACKED_UP_LEGACY_ROWS: 2, + RENAMED_LEGACY_JSON: true, + STATUS: 'success', + }), + ); + + emitStatusMock.mockClear(); + await run([]); + expect(emitStatusMock).toHaveBeenLastCalledWith( + 'MIGRATE_ROOM_REGISTRATIONS', + expect.objectContaining({ + MIGRATED_ROOMS: 0, + MIGRATED_ROLE_OVERRIDES: 0, + SKIPPED_ROOMS: 0, + MIGRATED_JSON_ROOMS: 0, + SKIPPED_JSON_ROOMS: 0, + BACKED_UP_LEGACY_ROWS: 0, + RENAMED_LEGACY_JSON: false, + STATUS: 'success', + }), + ); + }); + + it('backfills room_role_overrides for rooms that already have room_settings', async () => { + const tempRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'ejclaw-migrate-rooms-mixed-'), + ); + tempRoots.push(tempRoot); + const storeDir = path.join(tempRoot, 'store'); + const dataDir = path.join(tempRoot, 'data'); + fs.mkdirSync(storeDir, { recursive: true }); + fs.mkdirSync(dataDir, { recursive: true }); + vi.stubEnv('EJCLAW_STORE_DIR', storeDir); + vi.stubEnv('EJCLAW_DATA_DIR', dataDir); + + const dbPath = path.join(storeDir, 'messages.db'); + const legacyDb = new Database(dbPath); + legacyDb.exec(` + CREATE TABLE room_settings ( + chat_jid TEXT PRIMARY KEY, + room_mode TEXT NOT NULL, + mode_source TEXT NOT NULL DEFAULT 'explicit', + name TEXT, + folder TEXT, + trigger_pattern TEXT, + requires_trigger INTEGER DEFAULT 1, + is_main INTEGER DEFAULT 0, + owner_agent_type TEXT, + work_dir TEXT, + updated_at TEXT NOT NULL + ); + 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) + ) + `); + legacyDb + .prepare( + `INSERT INTO room_settings ( + chat_jid, + room_mode, + mode_source, + name, + folder, + trigger_pattern, + requires_trigger, + is_main, + owner_agent_type, + work_dir, + updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + 'dc:mixed-room', + 'tribunal', + 'explicit', + 'Mixed Room', + 'mixed-room', + '@Andy', + 1, + 0, + 'codex', + null, + '2026-04-10T00:00:00.000Z', + ); + legacyDb + .prepare( + `INSERT INTO registered_groups ( + jid, + name, + folder, + trigger_pattern, + added_at, + agent_config, + requires_trigger, + is_main, + agent_type, + work_dir + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + 'dc:mixed-room', + 'Mixed Room', + 'mixed-room', + '@Andy', + '2026-04-08T00:00:00.000Z', + '{"provider":"openai"}', + 1, + 0, + 'codex', + null, + ); + legacyDb + .prepare( + `INSERT INTO registered_groups ( + jid, + name, + folder, + trigger_pattern, + added_at, + agent_config, + requires_trigger, + is_main, + agent_type, + work_dir + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + 'dc:mixed-room', + 'Mixed Room', + 'mixed-room', + '@Andy', + '2026-04-09T00:00:00.000Z', + '{"provider":"anthropic"}', + 1, + 0, + 'claude-code', + null, + ); + legacyDb.close(); + + const emitStatusMock = vi.fn(); + vi.doMock('./status.js', () => ({ + emitStatus: emitStatusMock, + })); + vi.doMock('../src/logger.js', () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + }, + })); + + const { run } = await import('./migrate-room-registrations.js'); + + await run([]); + + const migratedDb = new Database(dbPath, { readonly: true }); + expect( + migratedDb + .prepare( + `SELECT chat_jid, role, agent_type, agent_config_json + FROM room_role_overrides + ORDER BY role`, + ) + .all(), + ).toEqual([ + { + chat_jid: 'dc:mixed-room', + role: 'owner', + agent_type: 'codex', + agent_config_json: '{"provider":"openai"}', + }, + { + chat_jid: 'dc:mixed-room', + role: 'reviewer', + agent_type: 'claude-code', + agent_config_json: '{"provider":"anthropic"}', + }, + ]); + migratedDb.close(); + + expect(emitStatusMock).toHaveBeenLastCalledWith( + 'MIGRATE_ROOM_REGISTRATIONS', + expect.objectContaining({ + MIGRATED_ROOMS: 0, + MIGRATED_ROLE_OVERRIDES: 2, + BACKED_UP_LEGACY_ROWS: 2, + STATUS: 'success', + }), + ); + }); + + it('fails and leaves registered_groups.json in place when it contains invalid entries', async () => { + const tempRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'ejclaw-migrate-rooms-invalid-json-'), + ); + tempRoots.push(tempRoot); + const storeDir = path.join(tempRoot, 'store'); + const dataDir = path.join(tempRoot, 'data'); + fs.mkdirSync(storeDir, { recursive: true }); + fs.mkdirSync(dataDir, { recursive: true }); + vi.stubEnv('EJCLAW_STORE_DIR', storeDir); + vi.stubEnv('EJCLAW_DATA_DIR', dataDir); + + fs.writeFileSync( + path.join(dataDir, 'registered_groups.json'), + JSON.stringify({ + 'dc:bad-room': { + name: 'Bad Room', + folder: '../bad', + trigger: '@Andy', + added_at: '2024-02-01T00:00:00.000Z', + }, + }), + ); + + vi.doMock('./status.js', () => ({ + emitStatus: vi.fn(), + })); + vi.doMock('../src/logger.js', () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + }, + })); + + const { run } = await import('./migrate-room-registrations.js'); + + await expect(run([])).rejects.toThrow( + /Invalid legacy registered_groups\.json folder/, + ); + expect(fs.existsSync(path.join(dataDir, 'registered_groups.json'))).toBe( + true, + ); + expect( + fs.existsSync(path.join(dataDir, 'registered_groups.json.migrated')), + ).toBe(false); + + const migratedDb = new Database(path.join(storeDir, 'messages.db')); + expect( + migratedDb + .prepare( + `SELECT name + FROM sqlite_master + WHERE type = 'table' + AND name IN ('room_settings', 'registered_groups_legacy_backup')`, + ) + .all(), + ).toEqual([{ name: 'room_settings' }]); + expect( + migratedDb.prepare('SELECT COUNT(*) AS count FROM room_settings').get(), + ).toEqual({ count: 0 }); + migratedDb.close(); + }); + + it('treats identical registered_groups.json entries as idempotent when room_settings already exists', async () => { + const tempRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'ejclaw-migrate-rooms-json-idempotent-'), + ); + tempRoots.push(tempRoot); + const storeDir = path.join(tempRoot, 'store'); + const dataDir = path.join(tempRoot, 'data'); + fs.mkdirSync(storeDir, { recursive: true }); + fs.mkdirSync(dataDir, { recursive: true }); + vi.stubEnv('EJCLAW_STORE_DIR', storeDir); + vi.stubEnv('EJCLAW_DATA_DIR', dataDir); + + const dbPath = path.join(storeDir, 'messages.db'); + const db = new Database(dbPath); + db.exec(` + CREATE TABLE room_settings ( + chat_jid TEXT PRIMARY KEY, + room_mode TEXT NOT NULL, + mode_source TEXT NOT NULL DEFAULT 'explicit', + name TEXT, + folder TEXT, + trigger_pattern TEXT, + requires_trigger INTEGER DEFAULT 1, + is_main INTEGER DEFAULT 0, + owner_agent_type TEXT, + work_dir TEXT, + created_at TEXT, + updated_at TEXT NOT NULL + ); + CREATE TABLE room_role_overrides ( + chat_jid TEXT NOT NULL, + role TEXT NOT NULL, + agent_type TEXT NOT NULL, + agent_config_json TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (chat_jid, role) + ); + `); + db.prepare( + `INSERT INTO room_settings ( + chat_jid, + room_mode, + mode_source, + name, + folder, + trigger_pattern, + requires_trigger, + is_main, + owner_agent_type, + work_dir, + created_at, + updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ).run( + 'dc:json-room', + 'single', + 'inferred', + 'JSON Room', + 'json-room', + '@Andy', + 1, + 0, + 'claude-code', + null, + '2024-02-01T00:00:00.000Z', + '2024-02-01T00:00:00.000Z', + ); + db.close(); + + fs.writeFileSync( + path.join(dataDir, 'registered_groups.json'), + JSON.stringify({ + 'dc:json-room': { + name: 'JSON Room', + folder: 'json-room', + trigger: '@Andy', + added_at: '2024-02-01T00:00:00.000Z', + agentType: 'claude-code', + requiresTrigger: true, + isMain: false, + }, + }), + ); + + const emitStatusMock = vi.fn(); + vi.doMock('./status.js', () => ({ + emitStatus: emitStatusMock, + })); + vi.doMock('../src/logger.js', () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + }, + })); + + const { run } = await import('./migrate-room-registrations.js'); + + await run([]); + + const migratedDb = new Database(dbPath, { readonly: true }); + expect( + migratedDb + .prepare( + `SELECT chat_jid, role, agent_type + FROM room_role_overrides + ORDER BY chat_jid, role`, + ) + .all(), + ).toEqual([ + { + chat_jid: 'dc:json-room', + role: 'owner', + agent_type: 'claude-code', + }, + ]); + migratedDb.close(); + + expect(fs.existsSync(path.join(dataDir, 'registered_groups.json'))).toBe( + false, + ); + expect( + fs.existsSync(path.join(dataDir, 'registered_groups.json.migrated')), + ).toBe(true); + expect(emitStatusMock).toHaveBeenLastCalledWith( + 'MIGRATE_ROOM_REGISTRATIONS', + expect.objectContaining({ + MIGRATED_ROOMS: 0, + MIGRATED_JSON_ROOMS: 0, + MIGRATED_ROLE_OVERRIDES: 1, + RENAMED_LEGACY_JSON: true, + STATUS: 'success', + }), + ); + }); + + it('fails and preserves registered_groups.json when table and json owner overrides conflict', async () => { + const tempRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'ejclaw-migrate-rooms-json-conflict-'), + ); + tempRoots.push(tempRoot); + const storeDir = path.join(tempRoot, 'store'); + const dataDir = path.join(tempRoot, 'data'); + fs.mkdirSync(storeDir, { recursive: true }); + fs.mkdirSync(dataDir, { recursive: true }); + vi.stubEnv('EJCLAW_STORE_DIR', storeDir); + vi.stubEnv('EJCLAW_DATA_DIR', dataDir); + + const dbPath = path.join(storeDir, 'messages.db'); + const db = new Database(dbPath); + db.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) + ) + `); + db.prepare( + `INSERT INTO registered_groups ( + jid, + name, + folder, + trigger_pattern, + added_at, + agent_config, + requires_trigger, + is_main, + agent_type, + work_dir + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ).run( + 'dc:shared-room', + 'Shared Room', + 'shared-room', + '@Andy', + '2024-03-01T00:00:00.000Z', + '{"source":"table"}', + 1, + 0, + 'claude-code', + null, + ); + db.close(); + + fs.writeFileSync( + path.join(dataDir, 'registered_groups.json'), + JSON.stringify({ + 'dc:shared-room': { + name: 'Shared Room', + folder: 'shared-room', + trigger: '@Andy', + added_at: '2024-03-01T00:00:00.000Z', + agentType: 'claude-code', + agentConfig: { source: 'json' }, + requiresTrigger: true, + isMain: false, + }, + }), + ); + + vi.doMock('./status.js', () => ({ + emitStatus: vi.fn(), + })); + vi.doMock('../src/logger.js', () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + }, + })); + + const { run } = await import('./migrate-room-registrations.js'); + + await expect(run([])).rejects.toThrow( + /owner override conflicts with existing owner override/, + ); + + const migratedDb = new Database(dbPath, { readonly: true }); + expect( + migratedDb.prepare('SELECT COUNT(*) AS count FROM room_settings').get(), + ).toEqual({ count: 0 }); + expect( + migratedDb + .prepare('SELECT COUNT(*) AS count FROM room_role_overrides') + .get(), + ).toEqual({ count: 0 }); + migratedDb.close(); + + expect(fs.existsSync(path.join(dataDir, 'registered_groups.json'))).toBe( + true, + ); + expect( + fs.existsSync(path.join(dataDir, 'registered_groups.json.migrated')), + ).toBe(false); + }); +}); diff --git a/setup/migrate-room-registrations.ts b/setup/migrate-room-registrations.ts new file mode 100644 index 0000000..0525e6e --- /dev/null +++ b/setup/migrate-room-registrations.ts @@ -0,0 +1,371 @@ +import fs from 'fs'; +import path from 'path'; + +import { Database } from 'bun:sqlite'; + +import { DATA_DIR, STORE_DIR } from '../src/config.js'; +import { + initializeDatabaseSchema, + openPersistentDatabase, +} from '../src/db/bootstrap.js'; +import { + buildLegacyRoomMigrationPlan, + getPendingLegacyRegisteredGroupJids, + getStoredRoomSettingsRowFromDatabase, + insertStoredRoomSettingsFromMigration, + normalizeStoredAgentType, + upsertRoomRoleOverride, +} from '../src/db/room-registration.js'; +import type { RegisteredGroup } from '../src/types.js'; +import { isValidGroupFolder } from '../src/group-folder.js'; +import { logger } from '../src/logger.js'; +import { readJsonFile } from '../src/utils.js'; +import { emitStatus } from './status.js'; + +interface MigrationReport { + migratedRooms: number; + migratedRoleOverrides: number; + skippedRooms: number; + migratedJsonRooms: number; + skippedJsonRooms: number; + backedUpLegacyRows: number; + renamedLegacyJson: boolean; +} + +interface StoredOwnerRoleOverride { + agentType: string; + agentConfig?: RegisteredGroup['agentConfig']; +} + +function normalizeAgentConfigValue( + agentConfig: RegisteredGroup['agentConfig'] | undefined, +): string { + return JSON.stringify(agentConfig ?? null); +} + +function jsonEntryMatchesStoredRoom( + jid: string, + group: RegisteredGroup, + database: Database, +): boolean { + const existing = getStoredRoomSettingsRowFromDatabase(database, jid); + if (!existing) { + return false; + } + + const ownerAgentType = + normalizeStoredAgentType(group.agentType) ?? 'claude-code'; + return ( + existing.roomMode === 'single' && + existing.name === group.name && + existing.folder === group.folder && + existing.trigger === group.trigger && + (existing.requiresTrigger ?? true) === (group.requiresTrigger ?? true) && + (existing.isMain ?? false) === (group.isMain ?? false) && + existing.ownerAgentType === ownerAgentType && + (existing.workDir ?? null) === (group.workDir ?? null) + ); +} + +function getStoredOwnerRoleOverride( + database: Database, + jid: string, +): StoredOwnerRoleOverride | undefined { + try { + const row = database + .prepare( + `SELECT agent_type, agent_config_json + FROM room_role_overrides + WHERE chat_jid = ? + AND role = 'owner'`, + ) + .get(jid) as + | { + agent_type: string | null; + agent_config_json: string | null; + } + | undefined; + const agentType = normalizeStoredAgentType(row?.agent_type); + if (!agentType) { + return undefined; + } + return { + agentType, + agentConfig: row?.agent_config_json + ? JSON.parse(row.agent_config_json) + : undefined, + }; + } catch { + return undefined; + } +} + +function jsonEntryMatchesStoredOwnerOverride( + database: Database, + jid: string, + group: RegisteredGroup, +): boolean { + const existingOverride = getStoredOwnerRoleOverride(database, jid); + if (!existingOverride) { + return false; + } + + const ownerAgentType = + normalizeStoredAgentType(group.agentType) ?? 'claude-code'; + return ( + existingOverride.agentType === ownerAgentType && + normalizeAgentConfigValue(existingOverride.agentConfig) === + normalizeAgentConfigValue(group.agentConfig) + ); +} + +function ensureLegacyBackupTable(database: Database): void { + database.exec(` + CREATE TABLE IF NOT EXISTS registered_groups_legacy_backup ( + 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, + is_main INTEGER, + agent_type TEXT, + work_dir TEXT, + backup_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (jid, agent_type) + ) + `); +} + +function backupPendingLegacyRows(database: Database): number { + ensureLegacyBackupTable(database); + const pendingJids = new Set(getPendingLegacyRegisteredGroupJids(database)); + if (pendingJids.size === 0) { + return 0; + } + const rows = ( + database + .prepare( + `SELECT jid, name, folder, trigger_pattern, added_at, agent_config, + requires_trigger, is_main, agent_type, work_dir + FROM registered_groups`, + ) + .all() as Array> + ).filter((row) => pendingJids.has(String(row.jid))); + + const insert = database.prepare( + `INSERT OR REPLACE INTO registered_groups_legacy_backup ( + jid, + name, + folder, + trigger_pattern, + added_at, + agent_config, + requires_trigger, + is_main, + agent_type, + work_dir, + backup_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)`, + ); + + for (const row of rows) { + insert.run( + row.jid, + row.name, + row.folder, + row.trigger_pattern, + row.added_at, + row.agent_config ?? null, + row.requires_trigger ?? null, + row.is_main ?? null, + row.agent_type ?? null, + row.work_dir ?? null, + ); + } + + return rows.length; +} + +function migrateLegacyRegisteredGroupsTable( + database: Database, + report: MigrationReport, +): void { + const rows = getPendingLegacyRegisteredGroupJids(database).map((jid) => ({ + jid, + })); + + for (const row of rows) { + const plan = buildLegacyRoomMigrationPlan(database, row.jid); + if (!plan) { + report.skippedRooms += 1; + continue; + } + + const existing = database + .prepare('SELECT 1 FROM room_settings WHERE chat_jid = ?') + .get(row.jid); + if (!existing) { + insertStoredRoomSettingsFromMigration(database, plan); + report.migratedRooms += 1; + } + for (const override of plan.roleOverrides) { + upsertRoomRoleOverride(database, row.jid, override); + report.migratedRoleOverrides += 1; + } + } +} + +function loadValidatedLegacyRegisteredGroupsJson( + database: Database, +): Array<{ jid: string; group: RegisteredGroup }> { + const legacyJsonPath = path.join(DATA_DIR, 'registered_groups.json'); + const legacyJson = readJsonFile(legacyJsonPath) as Record< + string, + RegisteredGroup + > | null; + + if (!legacyJson) { + return []; + } + + const entries = Object.entries(legacyJson); + for (const [jid, group] of entries) { + if (!isValidGroupFolder(group.folder)) { + throw new Error( + `Invalid legacy registered_groups.json folder for ${jid}: ${group.folder}`, + ); + } + + const existing = getStoredRoomSettingsRowFromDatabase(database, jid); + if (existing && !jsonEntryMatchesStoredRoom(jid, group, database)) { + throw new Error( + `Legacy registered_groups.json entry collides with existing room_settings row for ${jid}`, + ); + } + } + + return entries.map(([jid, group]) => ({ jid, group })); +} + +function migrateLegacyRegisteredGroupsJson( + database: Database, + projectRoot: string, + report: MigrationReport, +): void { + const legacyJsonPath = path.join(DATA_DIR, 'registered_groups.json'); + const legacyJsonEntries = loadValidatedLegacyRegisteredGroupsJson(database); + if (legacyJsonEntries.length === 0) { + return; + } + + const insertRoom = database.prepare( + `INSERT INTO room_settings ( + chat_jid, + room_mode, + mode_source, + name, + folder, + trigger_pattern, + requires_trigger, + is_main, + owner_agent_type, + work_dir, + created_at, + updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(chat_jid) DO NOTHING`, + ); + + for (const { jid, group } of legacyJsonEntries) { + const createdAt = group.added_at || new Date().toISOString(); + const ownerAgentType = + normalizeStoredAgentType(group.agentType) ?? 'claude-code'; + const existing = getStoredRoomSettingsRowFromDatabase(database, jid); + const existingOwnerOverride = getStoredOwnerRoleOverride(database, jid); + + if (!existing) { + insertRoom.run( + jid, + 'single', + 'inferred', + group.name, + group.folder, + group.trigger, + (group.requiresTrigger ?? true) ? 1 : 0, + (group.isMain ?? false) ? 1 : 0, + ownerAgentType, + group.workDir ?? null, + createdAt, + createdAt, + ); + report.migratedRooms += 1; + report.migratedJsonRooms += 1; + } + + if ( + existingOwnerOverride && + !jsonEntryMatchesStoredOwnerOverride(database, jid, group) + ) { + throw new Error( + `Legacy registered_groups.json owner override conflicts with existing owner override for ${jid}`, + ); + } + + if (!existingOwnerOverride) { + upsertRoomRoleOverride(database, jid, { + role: 'owner', + agentType: ownerAgentType, + agentConfig: group.agentConfig, + createdAt, + updatedAt: createdAt, + }); + report.migratedRoleOverrides += 1; + } + } + + fs.renameSync(legacyJsonPath, `${legacyJsonPath}.migrated`); + report.renamedLegacyJson = true; + logger.info( + { legacyJsonPath, projectRoot }, + 'Renamed registered_groups.json after explicit migration', + ); +} + +export async function run(_args: string[]): Promise { + fs.mkdirSync(STORE_DIR, { recursive: true }); + const projectRoot = process.cwd(); + const database = openPersistentDatabase(); + initializeDatabaseSchema(database); + + const report: MigrationReport = { + migratedRooms: 0, + migratedRoleOverrides: 0, + skippedRooms: 0, + migratedJsonRooms: 0, + skippedJsonRooms: 0, + backedUpLegacyRows: 0, + renamedLegacyJson: false, + }; + + database.transaction(() => { + report.backedUpLegacyRows = backupPendingLegacyRows(database); + migrateLegacyRegisteredGroupsTable(database, report); + migrateLegacyRegisteredGroupsJson(database, projectRoot, report); + })(); + + database.close(); + + emitStatus('MIGRATE_ROOM_REGISTRATIONS', { + MIGRATED_ROOMS: report.migratedRooms, + MIGRATED_ROLE_OVERRIDES: report.migratedRoleOverrides, + SKIPPED_ROOMS: report.skippedRooms, + MIGRATED_JSON_ROOMS: report.migratedJsonRooms, + SKIPPED_JSON_ROOMS: report.skippedJsonRooms, + BACKED_UP_LEGACY_ROWS: report.backedUpLegacyRows, + RENAMED_LEGACY_JSON: report.renamedLegacyJson, + STATUS: 'success', + LOG: 'logs/setup.log', + }); +} diff --git a/setup/register.test.ts b/setup/register.test.ts index 9efb7d9..55bfe5f 100644 --- a/setup/register.test.ts +++ b/setup/register.test.ts @@ -1,261 +1,163 @@ -import { describe, it, expect, beforeEach } from 'vitest'; +import fs from 'fs'; +import { afterEach, describe, expect, it, vi } from 'vitest'; -import { Database } from 'bun:sqlite'; +const { + assignRoomMock, + initDatabaseMock, + emitStatusMock, + loggerInfoMock, + isValidGroupFolderMock, +} = vi.hoisted(() => ({ + assignRoomMock: vi.fn(), + initDatabaseMock: vi.fn(), + emitStatusMock: vi.fn(), + loggerInfoMock: vi.fn(), + isValidGroupFolderMock: vi.fn(() => true), +})); -/** - * Tests for the register step. - * - * Verifies: parameterized SQL (no injection), file templating, - * apostrophe in names, .env updates. - */ +vi.mock('../src/config.js', () => ({ + GROUPS_DIR: '/tmp/ejclaw-groups', +})); -function createTestDb(): Database { - const db = new Database(':memory:'); - db.exec(`CREATE TABLE IF NOT EXISTS 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) - )`); - return db; -} +vi.mock('../src/db.js', () => ({ + assignRoom: assignRoomMock, + initDatabase: initDatabaseMock, +})); -describe('parameterized SQL registration', () => { - let db: Database; +vi.mock('../src/group-folder.js', () => ({ + isValidGroupFolder: isValidGroupFolderMock, +})); - beforeEach(() => { - db = createTestDb(); +vi.mock('../src/logger.js', () => ({ + logger: { + info: loggerInfoMock, + }, +})); + +vi.mock('./status.js', () => ({ + emitStatus: emitStatusMock, +})); + +import { run } from './register.js'; + +describe('register step', () => { + afterEach(() => { + assignRoomMock.mockReset(); + initDatabaseMock.mockReset(); + emitStatusMock.mockReset(); + loggerInfoMock.mockReset(); + isValidGroupFolderMock.mockReset(); + isValidGroupFolderMock.mockReturnValue(true); + vi.restoreAllMocks(); }); - it('registers a group with parameterized query', () => { - db.prepare( - `INSERT OR REPLACE INTO registered_groups - (jid, name, folder, trigger_pattern, added_at, agent_config, requires_trigger) - VALUES (?, ?, ?, ?, ?, NULL, ?)`, - ).run( - '123@g.us', - 'Test Group', - 'test-group', + it('delegates room assignment to the canonical service without editing env or prompts', async () => { + const mkdirSpy = vi + .spyOn(fs, 'mkdirSync') + .mockImplementation(() => undefined); + const readSpy = vi.spyOn(fs, 'readFileSync'); + const writeSpy = vi.spyOn(fs, 'writeFileSync'); + + await run([ + '--jid', + 'dc:test-room', + '--name', + 'Test Room', + '--trigger', '@Andy', - '2024-01-01T00:00:00.000Z', - 1, - ); + '--folder', + 'test-room', + ]); - const row = db - .prepare('SELECT * FROM registered_groups WHERE jid = ?') - .get('123@g.us') as { - jid: string; - name: string; - folder: string; - trigger_pattern: string; - requires_trigger: number; - }; - - expect(row.jid).toBe('123@g.us'); - expect(row.name).toBe('Test Group'); - expect(row.folder).toBe('test-group'); - expect(row.trigger_pattern).toBe('@Andy'); - expect(row.requires_trigger).toBe(1); + expect(initDatabaseMock).toHaveBeenCalledTimes(1); + expect(assignRoomMock).toHaveBeenCalledWith('dc:test-room', { + name: 'Test Room', + folder: 'test-room', + trigger: '@Andy', + requiresTrigger: true, + isMain: false, + }); + expect(mkdirSpy).toHaveBeenCalledWith('/tmp/ejclaw-groups/test-room/logs', { + recursive: true, + }); + expect(readSpy).not.toHaveBeenCalled(); + expect(writeSpy).not.toHaveBeenCalled(); + expect(emitStatusMock).toHaveBeenCalledWith('REGISTER_CHANNEL', { + JID: 'dc:test-room', + NAME: 'Test Room', + FOLDER: 'test-room', + CHANNEL: 'discord', + TRIGGER: '@Andy', + REQUIRES_TRIGGER: true, + STATUS: 'success', + LOG: 'logs/setup.log', + }); }); - it('handles apostrophes in group names safely', () => { - const name = "O'Brien's Group"; + it('fails fast when deprecated assistant-name is passed', async () => { + const exitSpy = vi + .spyOn(process, 'exit') + .mockImplementation((code?: string | number | null | undefined) => { + throw new Error(`process.exit:${code}`); + }); - db.prepare( - `INSERT OR REPLACE INTO registered_groups - (jid, name, folder, trigger_pattern, added_at, agent_config, requires_trigger) - VALUES (?, ?, ?, ?, ?, NULL, ?)`, - ).run( - '456@g.us', - name, - 'obriens-group', + await expect( + run([ + '--jid', + 'dc:test-room', + '--name', + 'Test Room', + '--trigger', + '@Andy', + '--folder', + 'test-room', + '--assistant-name', + 'Nova', + ]), + ).rejects.toThrow('process.exit:4'); + + expect(initDatabaseMock).not.toHaveBeenCalled(); + expect(assignRoomMock).not.toHaveBeenCalled(); + expect(emitStatusMock).toHaveBeenCalledWith('REGISTER_CHANNEL', { + STATUS: 'failed', + ERROR: 'assistant_name_option_removed', + NEXT_STEP: + 'Use a dedicated assistant identity configuration command instead', + LOG: 'logs/setup.log', + }); + + exitSpy.mockRestore(); + }); + + it('never writes prompt or env files during registration', async () => { + const mkdirSpy = vi + .spyOn(fs, 'mkdirSync') + .mockImplementation(() => undefined); + const readSpy = vi.spyOn(fs, 'readFileSync'); + const writeSpy = vi.spyOn(fs, 'writeFileSync'); + + await run([ + '--jid', + 'dc:main-room', + '--name', + 'Main Room', + '--trigger', '@Andy', - '2024-01-01T00:00:00.000Z', - 0, - ); + '--folder', + 'main-room', + '--is-main', + '--no-trigger-required', + ]); - const row = db - .prepare('SELECT name FROM registered_groups WHERE jid = ?') - .get('456@g.us') as { - name: string; - }; - - expect(row.name).toBe(name); - }); - - it('prevents SQL injection in JID field', () => { - const maliciousJid = "'; DROP TABLE registered_groups; --"; - - db.prepare( - `INSERT OR REPLACE INTO registered_groups - (jid, name, folder, trigger_pattern, added_at, agent_config, requires_trigger) - VALUES (?, ?, ?, ?, ?, NULL, ?)`, - ).run(maliciousJid, 'Evil', 'evil', '@Andy', '2024-01-01T00:00:00.000Z', 1); - - // Table should still exist and have the row - const count = db - .prepare('SELECT COUNT(*) as count FROM registered_groups') - .get() as { - count: number; - }; - expect(count.count).toBe(1); - - const row = db.prepare('SELECT jid FROM registered_groups').get() as { - jid: string; - }; - expect(row.jid).toBe(maliciousJid); - }); - - it('handles requiresTrigger=false', () => { - db.prepare( - `INSERT OR REPLACE INTO registered_groups - (jid, name, folder, trigger_pattern, added_at, agent_config, requires_trigger) - VALUES (?, ?, ?, ?, ?, NULL, ?)`, - ).run( - 'dc:789', - 'Personal', - 'main', - '@Andy', - '2024-01-01T00:00:00.000Z', - 0, - ); - - const row = db - .prepare('SELECT requires_trigger FROM registered_groups WHERE jid = ?') - .get('dc:789') as { requires_trigger: number }; - - expect(row.requires_trigger).toBe(0); - }); - - it('stores is_main flag', () => { - db.prepare( - `INSERT OR REPLACE INTO registered_groups - (jid, name, folder, trigger_pattern, added_at, agent_config, requires_trigger, is_main) - VALUES (?, ?, ?, ?, ?, NULL, ?, ?)`, - ).run( - 'dc:789', - 'Personal', - 'discord_main', - '@Andy', - '2024-01-01T00:00:00.000Z', - 0, - 1, - ); - - const row = db - .prepare('SELECT is_main FROM registered_groups WHERE jid = ?') - .get('dc:789') as { is_main: number }; - - expect(row.is_main).toBe(1); - }); - - it('defaults is_main to 0', () => { - db.prepare( - `INSERT OR REPLACE INTO registered_groups - (jid, name, folder, trigger_pattern, added_at, agent_config, requires_trigger) - VALUES (?, ?, ?, ?, ?, NULL, ?)`, - ).run( - '123@g.us', - 'Some Group', - 'discord_some-group', - '@Andy', - '2024-01-01T00:00:00.000Z', - 1, - ); - - const row = db - .prepare('SELECT is_main FROM registered_groups WHERE jid = ?') - .get('123@g.us') as { is_main: number }; - - expect(row.is_main).toBe(0); - }); - - it('upserts on conflict', () => { - const stmt = db.prepare( - `INSERT OR REPLACE INTO registered_groups - (jid, name, folder, trigger_pattern, added_at, agent_config, requires_trigger) - VALUES (?, ?, ?, ?, ?, NULL, ?)`, - ); - - stmt.run( - '123@g.us', - 'Original', - 'main', - '@Andy', - '2024-01-01T00:00:00.000Z', - 1, - ); - stmt.run( - '123@g.us', - 'Updated', - 'main', - '@Bot', - '2024-02-01T00:00:00.000Z', - 0, - ); - - const rows = db.prepare('SELECT * FROM registered_groups').all(); - expect(rows).toHaveLength(1); - - const row = rows[0] as { - name: string; - trigger_pattern: string; - requires_trigger: number; - }; - expect(row.name).toBe('Updated'); - expect(row.trigger_pattern).toBe('@Bot'); - expect(row.requires_trigger).toBe(0); - }); -}); - -describe('file templating', () => { - it('replaces assistant name in CLAUDE.md content', () => { - let content = '# Andy\n\nYou are Andy, a personal assistant.'; - - content = content.replace(/^# Andy$/m, '# Nova'); - content = content.replace(/You are Andy/g, 'You are Nova'); - - expect(content).toBe('# Nova\n\nYou are Nova, a personal assistant.'); - }); - - it('handles names with special regex characters', () => { - let content = '# Andy\n\nYou are Andy.'; - - const newName = 'C.L.A.U.D.E'; - content = content.replace(/^# Andy$/m, `# ${newName}`); - content = content.replace(/You are Andy/g, `You are ${newName}`); - - expect(content).toContain('# C.L.A.U.D.E'); - expect(content).toContain('You are C.L.A.U.D.E.'); - }); - - it('updates .env ASSISTANT_NAME line', () => { - let envContent = 'SOME_KEY=value\nASSISTANT_NAME="Andy"\nOTHER=test'; - - envContent = envContent.replace( - /^ASSISTANT_NAME=.*$/m, - 'ASSISTANT_NAME="Nova"', - ); - - expect(envContent).toContain('ASSISTANT_NAME="Nova"'); - expect(envContent).toContain('SOME_KEY=value'); - }); - - it('appends ASSISTANT_NAME to .env if not present', () => { - let envContent = 'SOME_KEY=value\n'; - - if (!envContent.includes('ASSISTANT_NAME=')) { - envContent += '\nASSISTANT_NAME="Nova"'; - } - - expect(envContent).toContain('ASSISTANT_NAME="Nova"'); + expect(assignRoomMock).toHaveBeenCalledWith('dc:main-room', { + name: 'Main Room', + folder: 'main-room', + trigger: '@Andy', + requiresTrigger: false, + isMain: true, + }); + expect(mkdirSpy).toHaveBeenCalledTimes(1); + expect(readSpy).not.toHaveBeenCalled(); + expect(writeSpy).not.toHaveBeenCalled(); }); }); diff --git a/setup/register.ts b/setup/register.ts index dae4d56..656cc40 100644 --- a/setup/register.ts +++ b/setup/register.ts @@ -1,15 +1,13 @@ /** - * Step: register — Write channel registration config, create group folders. + * Step: register — Assign a room via the canonical application service. * * EJClaw is Discord-only, so registrations must target Discord channel IDs. - * Uses parameterized SQL queries to prevent injection. */ import fs from 'fs'; import path from 'path'; -import { Database } from 'bun:sqlite'; - -import { STORE_DIR } from '../src/config.js'; +import { GROUPS_DIR } from '../src/config.js'; +import { assignRoom, initDatabase } from '../src/db.js'; import { isValidGroupFolder } from '../src/group-folder.js'; import { logger } from '../src/logger.js'; import { emitStatus } from './status.js'; @@ -22,7 +20,7 @@ interface RegisterArgs { channel: string; requiresTrigger: boolean; isMain: boolean; - assistantName: string; + assistantNameProvided: boolean; } function parseArgs(args: string[]): RegisterArgs { @@ -34,7 +32,7 @@ function parseArgs(args: string[]): RegisterArgs { channel: 'discord', requiresTrigger: true, isMain: false, - assistantName: 'Andy', + assistantNameProvided: false, }; for (let i = 0; i < args.length; i++) { @@ -61,7 +59,8 @@ function parseArgs(args: string[]): RegisterArgs { result.isMain = true; break; case '--assistant-name': - result.assistantName = args[++i] || 'Andy'; + result.assistantNameProvided = true; + i += 1; break; } } @@ -70,7 +69,6 @@ function parseArgs(args: string[]): RegisterArgs { } export async function run(args: string[]): Promise { - const projectRoot = process.cwd(); const parsed = parseArgs(args); if (!parsed.jid || !parsed.name || !parsed.trigger || !parsed.folder) { @@ -100,118 +98,33 @@ export async function run(args: string[]): Promise { process.exit(4); } + if (parsed.assistantNameProvided) { + emitStatus('REGISTER_CHANNEL', { + STATUS: 'failed', + ERROR: 'assistant_name_option_removed', + NEXT_STEP: + 'Use a dedicated assistant identity configuration command instead', + LOG: 'logs/setup.log', + }); + process.exit(4); + } + logger.info(parsed, 'Registering channel'); - // Ensure data and store directories exist for fresh installs. - fs.mkdirSync(path.join(projectRoot, 'data'), { recursive: true }); - fs.mkdirSync(STORE_DIR, { recursive: true }); + initDatabase(); + assignRoom(parsed.jid, { + name: parsed.name, + folder: parsed.folder, + trigger: parsed.trigger, + requiresTrigger: parsed.requiresTrigger, + isMain: parsed.isMain, + }); + logger.info('Assigned room through canonical room service'); - // Write to SQLite using parameterized queries (no SQL injection) - const dbPath = path.join(STORE_DIR, 'messages.db'); - const timestamp = new Date().toISOString(); - const requiresTriggerInt = parsed.requiresTrigger ? 1 : 0; - - const db = new Database(dbPath); - // Ensure schema exists - db.exec(`CREATE TABLE IF NOT EXISTS 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) - )`); - - try { - db.exec( - `ALTER TABLE registered_groups ADD COLUMN agent_type TEXT DEFAULT 'claude-code'`, - ); - } catch { - /* column already exists */ - } - - try { - db.exec(`ALTER TABLE registered_groups ADD COLUMN work_dir TEXT`); - } catch { - /* column already exists */ - } - - const isMainInt = parsed.isMain ? 1 : 0; - - db.prepare( - `INSERT OR REPLACE INTO registered_groups - (jid, name, folder, trigger_pattern, added_at, agent_config, requires_trigger, is_main, agent_type, work_dir) - VALUES (?, ?, ?, ?, ?, NULL, ?, ?, ?, NULL)`, - ).run( - parsed.jid, - parsed.name, - parsed.folder, - parsed.trigger, - timestamp, - requiresTriggerInt, - isMainInt, - 'claude-code', - ); - - db.close(); - logger.info('Wrote registration to SQLite'); - - // Create group folders - fs.mkdirSync(path.join(projectRoot, 'groups', parsed.folder, 'logs'), { + fs.mkdirSync(path.join(GROUPS_DIR, parsed.folder, 'logs'), { recursive: true, }); - - // Update assistant name in CLAUDE.md files if different from default - let nameUpdated = false; - if (parsed.assistantName !== 'Andy') { - logger.info( - { from: 'Andy', to: parsed.assistantName }, - 'Updating assistant name', - ); - - const mdFiles = [ - path.join(projectRoot, 'groups', 'global', 'CLAUDE.md'), - path.join(projectRoot, 'groups', parsed.folder, 'CLAUDE.md'), - ]; - - for (const mdFile of mdFiles) { - if (fs.existsSync(mdFile)) { - let content = fs.readFileSync(mdFile, 'utf-8'); - content = content.replace(/^# Andy$/m, `# ${parsed.assistantName}`); - content = content.replace( - /You are Andy/g, - `You are ${parsed.assistantName}`, - ); - fs.writeFileSync(mdFile, content); - logger.info({ file: mdFile }, 'Updated CLAUDE.md'); - } - } - - // Update .env - const envFile = path.join(projectRoot, '.env'); - if (fs.existsSync(envFile)) { - let envContent = fs.readFileSync(envFile, 'utf-8'); - if (envContent.includes('ASSISTANT_NAME=')) { - envContent = envContent.replace( - /^ASSISTANT_NAME=.*$/m, - `ASSISTANT_NAME="${parsed.assistantName}"`, - ); - } else { - envContent += `\nASSISTANT_NAME="${parsed.assistantName}"`; - } - fs.writeFileSync(envFile, envContent); - } else { - fs.writeFileSync(envFile, `ASSISTANT_NAME="${parsed.assistantName}"\n`); - } - logger.info('Set ASSISTANT_NAME in .env'); - nameUpdated = true; - } + logger.info({ folder: parsed.folder }, 'Ensured group log directory exists'); emitStatus('REGISTER_CHANNEL', { JID: parsed.jid, @@ -220,8 +133,6 @@ export async function run(args: string[]): Promise { CHANNEL: parsed.channel, TRIGGER: parsed.trigger, REQUIRES_TRIGGER: parsed.requiresTrigger, - ASSISTANT_NAME: parsed.assistantName, - NAME_UPDATED: nameUpdated, STATUS: 'success', LOG: 'logs/setup.log', }); diff --git a/setup/room-registration-state.ts b/setup/room-registration-state.ts new file mode 100644 index 0000000..55b8a5c --- /dev/null +++ b/setup/room-registration-state.ts @@ -0,0 +1,149 @@ +import fs from 'fs'; +import path from 'path'; + +import { Database } from 'bun:sqlite'; + +import { STORE_DIR } from '../src/config.js'; +import { countPendingLegacyRegisteredGroupRows } from '../src/db/room-registration.js'; + +export interface AssignedRoomsSummary { + assignedRooms: number; + roomsByOwnerAgent: Record; + legacyRegisteredGroupRows: number; + hasLegacyRegisteredGroupsJson: boolean; + legacyRoomMigrationRequired: boolean; + pendingLegacyJsonStateFiles: string[]; + legacyJsonStateMigrationRequired: boolean; +} + +export interface LegacyMigrationGuidance { + error: string; + nextStep: string; +} + +function getDataDir(projectRoot: string): string { + return process.env.EJCLAW_DATA_DIR || path.join(projectRoot, 'data'); +} + +function getPendingLegacyJsonStateFiles(projectRoot: string): string[] { + const dataDir = getDataDir(projectRoot); + return ['router_state.json', 'sessions.json'].filter((filename) => + fs.existsSync(path.join(dataDir, filename)), + ); +} + +export function getLegacyMigrationGuidance( + summary: Pick< + AssignedRoomsSummary, + 'legacyRoomMigrationRequired' | 'legacyJsonStateMigrationRequired' + >, + target: 'setup' | 'verification', +): LegacyMigrationGuidance | undefined { + if ( + !summary.legacyRoomMigrationRequired && + !summary.legacyJsonStateMigrationRequired + ) { + return undefined; + } + + const steps: string[] = []; + if (summary.legacyRoomMigrationRequired) { + steps.push('bun setup/index.ts --step migrate-room-registrations'); + } + if (summary.legacyJsonStateMigrationRequired) { + steps.push('bun setup/index.ts --step migrate-json-state'); + } + + const error = + summary.legacyRoomMigrationRequired && + summary.legacyJsonStateMigrationRequired + ? 'legacy_migration_required' + : summary.legacyRoomMigrationRequired + ? 'legacy_room_migration_required' + : 'legacy_json_state_migration_required'; + const commandText = + steps.length === 1 + ? `Run \`${steps[0]}\` before continuing with ${target}` + : `Run \`${steps[0]}\` and \`${steps[1]}\` before continuing with ${target}`; + + return { + error, + nextStep: commandText, + }; +} + +export function detectRoomRegistrationState( + options: { + projectRoot?: string; + dbPath?: string; + } = {}, +): AssignedRoomsSummary { + const projectRoot = options.projectRoot ?? process.cwd(); + const dbPath = options.dbPath ?? path.join(STORE_DIR, 'messages.db'); + let assignedRooms = 0; + let legacyRegisteredGroupRows = 0; + const roomsByOwnerAgent: Record = {}; + + if (fs.existsSync(dbPath)) { + try { + const db = new Database(dbPath, { readonly: true }); + + try { + const row = db + .prepare('SELECT COUNT(*) as count FROM room_settings') + .get() as { count: number }; + assignedRooms = row.count; + } catch { + assignedRooms = 0; + } + + if (assignedRooms > 0) { + try { + const rows = db + .prepare( + `SELECT COALESCE(owner_agent_type, 'unknown') AS owner_agent_type, + COUNT(*) AS count + FROM room_settings + GROUP BY COALESCE(owner_agent_type, 'unknown')`, + ) + .all() as { owner_agent_type: string; count: number }[]; + for (const current of rows) { + roomsByOwnerAgent[current.owner_agent_type] = current.count; + } + } catch { + // room_settings might not exist in older schema + } + } + + try { + legacyRegisteredGroupRows = countPendingLegacyRegisteredGroupRows(db); + } catch { + legacyRegisteredGroupRows = 0; + } + + db.close(); + } catch { + // Database might not exist yet or schema might be incomplete + } + } + + const hasLegacyRegisteredGroupsJson = fs.existsSync( + path.join(getDataDir(projectRoot), 'registered_groups.json'), + ); + const pendingLegacyJsonStateFiles = + getPendingLegacyJsonStateFiles(projectRoot); + const legacyRoomMigrationRequired = + legacyRegisteredGroupRows > 0 || hasLegacyRegisteredGroupsJson; + const legacyJsonStateMigrationRequired = + pendingLegacyJsonStateFiles.length > 0; + + return { + assignedRooms, + roomsByOwnerAgent, + legacyRegisteredGroupRows, + hasLegacyRegisteredGroupsJson, + legacyRoomMigrationRequired, + pendingLegacyJsonStateFiles, + legacyJsonStateMigrationRequired, + }; +} diff --git a/setup/verify-state.test.ts b/setup/verify-state.test.ts index 054ffcb..3e2ec32 100644 --- a/setup/verify-state.test.ts +++ b/setup/verify-state.test.ts @@ -11,7 +11,7 @@ import { buildVerifySummary, detectChannelAuth, detectCredentials, - loadRegisteredGroupsSummary, + loadAssignedRoomsSummary, loadRoleRoutingRequirementsSummary, } from './verify-state.js'; @@ -69,9 +69,10 @@ describe('verify state helpers', () => { const dbPath = path.join(tempRoot, 'messages.db'); const db = new Database(dbPath); db.exec(` - CREATE TABLE registered_groups ( - jid TEXT NOT NULL, - agent_type TEXT + CREATE TABLE room_settings ( + chat_jid TEXT PRIMARY KEY, + room_mode TEXT NOT NULL, + updated_at TEXT NOT NULL ); `); db.exec(` @@ -82,10 +83,9 @@ describe('verify state helpers', () => { ); `); db.exec(` - INSERT INTO registered_groups (jid, agent_type) VALUES - ('group-1', 'claude-code'), - ('group-1', 'codex'), - ('group-2', 'claude-code'); + INSERT INTO room_settings (chat_jid, room_mode, updated_at) VALUES + ('group-1', 'tribunal', '2024-01-01T00:00:00.000Z'), + ('group-2', 'single', '2024-01-01T00:00:00.000Z'); `); db.exec(` INSERT INTO paired_tasks (id, chat_jid, status) VALUES @@ -94,13 +94,48 @@ describe('verify state helpers', () => { `); db.close(); - expect(loadRoleRoutingRequirementsSummary(dbPath)).toEqual({ + expect(loadRoleRoutingRequirementsSummary({ dbPath })).toEqual({ tribunalRooms: 1, activeArbiterTasks: 1, }); }); - it('loads registered group counts from the sqlite store', () => { + it('loads assigned room counts from the sqlite store', () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-verify-')); + tempRoots.push(tempRoot); + const dbPath = path.join(tempRoot, 'messages.db'); + const db = new Database(dbPath); + db.exec(` + CREATE TABLE room_settings ( + chat_jid TEXT PRIMARY KEY, + room_mode TEXT NOT NULL, + owner_agent_type TEXT, + updated_at TEXT NOT NULL + ); + `); + db.exec(` + INSERT INTO room_settings (chat_jid, room_mode, owner_agent_type, updated_at) VALUES + ('group-1', 'single', 'claude-code', '2024-01-01T00:00:00.000Z'), + ('group-2', 'single', 'codex', '2024-01-01T00:00:00.000Z'), + ('group-3', 'tribunal', 'codex', '2024-01-01T00:00:00.000Z'); + `); + db.close(); + + expect(loadAssignedRoomsSummary({ dbPath })).toEqual({ + assignedRooms: 3, + roomsByOwnerAgent: { + 'claude-code': 1, + codex: 2, + }, + legacyRegisteredGroupRows: 0, + hasLegacyRegisteredGroupsJson: false, + legacyRoomMigrationRequired: false, + pendingLegacyJsonStateFiles: [], + legacyJsonStateMigrationRequired: false, + }); + }); + + it('marks legacy-only sqlite registrations as migration-required', () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-verify-')); tempRoots.push(tempRoot); const dbPath = path.join(tempRoot, 'messages.db'); @@ -109,22 +144,307 @@ describe('verify state helpers', () => { CREATE TABLE registered_groups ( jid TEXT PRIMARY KEY, agent_type TEXT - ); + ) `); db.exec(` INSERT INTO registered_groups (jid, agent_type) VALUES - ('group-1', 'claude-code'), - ('group-2', 'codex'), - ('group-3', 'codex'); + ('legacy-room', 'claude-code') `); db.close(); - expect(loadRegisteredGroupsSummary(dbPath)).toEqual({ - registeredGroups: 3, - groupsByAgent: { - 'claude-code': 1, - codex: 2, + expect(loadAssignedRoomsSummary({ dbPath, projectRoot: tempRoot })).toEqual( + { + assignedRooms: 0, + roomsByOwnerAgent: {}, + legacyRegisteredGroupRows: 1, + hasLegacyRegisteredGroupsJson: false, + legacyRoomMigrationRequired: true, + pendingLegacyJsonStateFiles: [], + legacyJsonStateMigrationRequired: false, }, + ); + }); + + it('marks mixed canonical and pending legacy sqlite registrations as migration-required', () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-verify-')); + tempRoots.push(tempRoot); + const dbPath = path.join(tempRoot, 'messages.db'); + const db = new Database(dbPath); + db.exec(` + CREATE TABLE room_settings ( + chat_jid TEXT PRIMARY KEY, + room_mode TEXT NOT NULL, + mode_source TEXT NOT NULL DEFAULT 'explicit', + name TEXT, + folder TEXT, + trigger_pattern TEXT, + requires_trigger INTEGER DEFAULT 1, + is_main INTEGER DEFAULT 0, + owner_agent_type TEXT, + work_dir TEXT, + updated_at TEXT NOT NULL + ); + CREATE TABLE room_role_overrides ( + chat_jid TEXT NOT NULL, + role TEXT NOT NULL, + agent_type TEXT NOT NULL, + agent_config_json TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (chat_jid, role) + ); + 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, + work_dir TEXT, + PRIMARY KEY (jid, agent_type) + ) + `); + db.exec(` + INSERT INTO room_settings ( + chat_jid, + room_mode, + mode_source, + name, + folder, + trigger_pattern, + requires_trigger, + is_main, + owner_agent_type, + work_dir, + updated_at + ) VALUES ( + 'canonical-room', + 'single', + 'explicit', + 'Canonical Room', + 'canonical-room', + '@Andy', + 1, + 0, + 'codex', + NULL, + '2024-01-01T00:00:00.000Z' + ) + `); + db.exec(` + INSERT INTO registered_groups ( + jid, + name, + folder, + trigger_pattern, + added_at, + agent_config, + requires_trigger, + is_main, + agent_type, + work_dir + ) VALUES ( + 'legacy-room', + 'Legacy Room', + 'legacy-room', + '@Andy', + '2024-01-01T00:00:00.000Z', + NULL, + 1, + 0, + 'claude-code', + NULL + ) + `); + db.close(); + + expect(loadAssignedRoomsSummary({ dbPath, projectRoot: tempRoot })).toEqual( + { + assignedRooms: 1, + roomsByOwnerAgent: { + codex: 1, + }, + legacyRegisteredGroupRows: 1, + hasLegacyRegisteredGroupsJson: false, + legacyRoomMigrationRequired: true, + pendingLegacyJsonStateFiles: [], + legacyJsonStateMigrationRequired: false, + }, + ); + }); + + it('ignores legacy projection rows when matching canonical room_settings already exist', () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-verify-')); + tempRoots.push(tempRoot); + const dbPath = path.join(tempRoot, 'messages.db'); + const db = new Database(dbPath); + db.exec(` + CREATE TABLE room_settings ( + chat_jid TEXT PRIMARY KEY, + room_mode TEXT NOT NULL, + mode_source TEXT NOT NULL DEFAULT 'explicit', + name TEXT, + folder TEXT, + trigger_pattern TEXT, + requires_trigger INTEGER DEFAULT 1, + is_main INTEGER DEFAULT 0, + owner_agent_type TEXT, + work_dir TEXT, + updated_at TEXT NOT NULL + ); + CREATE TABLE room_role_overrides ( + chat_jid TEXT NOT NULL, + role TEXT NOT NULL, + agent_type TEXT NOT NULL, + agent_config_json TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (chat_jid, role) + ); + 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, + work_dir TEXT, + PRIMARY KEY (jid, agent_type) + ) + `); + db.exec(` + INSERT INTO room_settings ( + chat_jid, + room_mode, + mode_source, + name, + folder, + trigger_pattern, + requires_trigger, + is_main, + owner_agent_type, + work_dir, + updated_at + ) VALUES ( + 'same-room', + 'single', + 'explicit', + 'Same Room', + 'same-room', + '@Andy', + 1, + 0, + 'codex', + NULL, + '2024-01-01T00:00:00.000Z' + ) + `); + db.exec(` + INSERT INTO registered_groups ( + jid, + name, + folder, + trigger_pattern, + added_at, + agent_config, + requires_trigger, + is_main, + agent_type, + work_dir + ) VALUES ( + 'same-room', + 'Same Room', + 'same-room', + '@Andy', + '2024-01-01T00:00:00.000Z', + NULL, + 1, + 0, + 'codex', + NULL + ) + `); + db.exec(` + INSERT INTO room_role_overrides ( + chat_jid, + role, + agent_type, + agent_config_json, + created_at, + updated_at + ) VALUES ( + 'same-room', + 'owner', + 'codex', + NULL, + '2024-01-01T00:00:00.000Z', + '2024-01-01T00:00:00.000Z' + ) + `); + db.close(); + + expect(loadAssignedRoomsSummary({ dbPath, projectRoot: tempRoot })).toEqual( + { + assignedRooms: 1, + roomsByOwnerAgent: { + codex: 1, + }, + legacyRegisteredGroupRows: 0, + hasLegacyRegisteredGroupsJson: false, + legacyRoomMigrationRequired: false, + pendingLegacyJsonStateFiles: [], + legacyJsonStateMigrationRequired: false, + }, + ); + }); + + it('marks legacy registered_groups.json as migration-required', () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-verify-')); + tempRoots.push(tempRoot); + fs.mkdirSync(path.join(tempRoot, 'data'), { recursive: true }); + fs.writeFileSync( + path.join(tempRoot, 'data', 'registered_groups.json'), + '{"dc:legacy":{"name":"Legacy","folder":"legacy","trigger":"@Andy","added_at":"2024-01-01T00:00:00.000Z"}}', + ); + + expect(loadAssignedRoomsSummary({ projectRoot: tempRoot })).toEqual({ + assignedRooms: 0, + roomsByOwnerAgent: {}, + legacyRegisteredGroupRows: 0, + hasLegacyRegisteredGroupsJson: true, + legacyRoomMigrationRequired: true, + pendingLegacyJsonStateFiles: [], + legacyJsonStateMigrationRequired: false, + }); + }); + + it('marks pending legacy json state files as migration-required', () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-verify-')); + tempRoots.push(tempRoot); + fs.mkdirSync(path.join(tempRoot, 'data'), { recursive: true }); + fs.writeFileSync( + path.join(tempRoot, 'data', 'router_state.json'), + JSON.stringify({ last_timestamp: '1234' }), + ); + fs.writeFileSync( + path.join(tempRoot, 'data', 'sessions.json'), + JSON.stringify({ 'group-a': 'session-123' }), + ); + + expect(loadAssignedRoomsSummary({ projectRoot: tempRoot })).toEqual({ + assignedRooms: 0, + roomsByOwnerAgent: {}, + legacyRegisteredGroupRows: 0, + hasLegacyRegisteredGroupsJson: false, + legacyRoomMigrationRequired: false, + pendingLegacyJsonStateFiles: ['router_state.json', 'sessions.json'], + legacyJsonStateMigrationRequired: true, }); }); @@ -150,14 +470,21 @@ describe('verify state helpers', () => { 'discord-review': 'configured', 'discord-arbiter': 'configured', }, - 2, - { codex: 1 }, + { + assignedRooms: 2, + roomsByOwnerAgent: { codex: 1 }, + legacyRegisteredGroupRows: 0, + hasLegacyRegisteredGroupsJson: false, + legacyRoomMigrationRequired: false, + pendingLegacyJsonStateFiles: [], + legacyJsonStateMigrationRequired: false, + }, ), ).toMatchObject({ status: 'success', configuredChannels: ['discord', 'discord-review', 'discord-arbiter'], - codexConfigured: true, - reviewConfigured: true, + reviewerChannelConfigured: true, + arbiterChannelConfigured: true, servicesSummary: { ejclaw: 'running' }, }); }); @@ -171,8 +498,15 @@ describe('verify state helpers', () => { [], 'missing', {}, - 0, - {}, + { + assignedRooms: 0, + roomsByOwnerAgent: {}, + legacyRegisteredGroupRows: 0, + hasLegacyRegisteredGroupsJson: false, + legacyRoomMigrationRequired: false, + pendingLegacyJsonStateFiles: [], + legacyJsonStateMigrationRequired: false, + }, ), ).toMatchObject({ status: 'failed', @@ -190,14 +524,21 @@ describe('verify state helpers', () => { [], 'configured', { 'discord-review': 'configured' }, - 1, - {}, + { + assignedRooms: 1, + roomsByOwnerAgent: {}, + legacyRegisteredGroupRows: 0, + hasLegacyRegisteredGroupsJson: false, + legacyRoomMigrationRequired: false, + pendingLegacyJsonStateFiles: [], + legacyJsonStateMigrationRequired: false, + }, ), ).toMatchObject({ status: 'failed', configuredChannels: ['discord-review'], - codexConfigured: true, - reviewConfigured: false, + reviewerChannelConfigured: true, + arbiterChannelConfigured: false, servicesSummary: { ejclaw: 'running' }, }); }); @@ -211,15 +552,22 @@ describe('verify state helpers', () => { [], 'configured', { discord: 'configured' }, - 1, - {}, + { + assignedRooms: 1, + roomsByOwnerAgent: {}, + legacyRegisteredGroupRows: 0, + hasLegacyRegisteredGroupsJson: false, + legacyRoomMigrationRequired: false, + pendingLegacyJsonStateFiles: [], + legacyJsonStateMigrationRequired: false, + }, { tribunalRooms: 1 }, ), ).toMatchObject({ status: 'failed', configuredChannels: ['discord'], - codexConfigured: false, - reviewConfigured: false, + reviewerChannelConfigured: false, + arbiterChannelConfigured: false, tribunalRooms: 1, }); }); @@ -236,17 +584,75 @@ describe('verify state helpers', () => { discord: 'configured', 'discord-review': 'configured', }, - 1, - {}, + { + assignedRooms: 1, + roomsByOwnerAgent: {}, + legacyRegisteredGroupRows: 0, + hasLegacyRegisteredGroupsJson: false, + legacyRoomMigrationRequired: false, + pendingLegacyJsonStateFiles: [], + legacyJsonStateMigrationRequired: false, + }, { tribunalRooms: 1, activeArbiterTasks: 1 }, ), ).toMatchObject({ status: 'failed', configuredChannels: ['discord', 'discord-review'], - codexConfigured: true, - reviewConfigured: false, + reviewerChannelConfigured: true, + arbiterChannelConfigured: false, activeArbiterTasks: 1, }); }); + it('fails verification when legacy room migration is still required', () => { + const services: ServiceCheck[] = [{ name: 'ejclaw', status: 'running' }]; + + expect( + buildVerifySummary( + services, + [], + 'configured', + { discord: 'configured' }, + { + assignedRooms: 0, + roomsByOwnerAgent: {}, + legacyRegisteredGroupRows: 2, + hasLegacyRegisteredGroupsJson: false, + legacyRoomMigrationRequired: true, + pendingLegacyJsonStateFiles: [], + legacyJsonStateMigrationRequired: false, + }, + ), + ).toMatchObject({ + status: 'failed', + legacyRoomMigrationRequired: true, + legacyRegisteredGroupRows: 2, + }); + }); + + it('fails verification when legacy json state migration is still required', () => { + const services: ServiceCheck[] = [{ name: 'ejclaw', status: 'running' }]; + + expect( + buildVerifySummary( + services, + [], + 'configured', + { discord: 'configured' }, + { + assignedRooms: 1, + roomsByOwnerAgent: { codex: 1 }, + legacyRegisteredGroupRows: 0, + hasLegacyRegisteredGroupsJson: false, + legacyRoomMigrationRequired: false, + pendingLegacyJsonStateFiles: ['router_state.json'], + legacyJsonStateMigrationRequired: true, + }, + ), + ).toMatchObject({ + status: 'failed', + legacyJsonStateMigrationRequired: true, + pendingLegacyJsonStateFiles: ['router_state.json'], + }); + }); }); diff --git a/setup/verify-state.ts b/setup/verify-state.ts index b435f70..f950d3f 100644 --- a/setup/verify-state.ts +++ b/setup/verify-state.ts @@ -3,34 +3,32 @@ import path from 'path'; import { Database } from 'bun:sqlite'; -import { STORE_DIR } from '../src/config.js'; import { readEnvFile } from '../src/env.js'; +import { STORE_DIR } from '../src/config.js'; +import { + type AssignedRoomsSummary, + detectRoomRegistrationState, +} from './room-registration-state.js'; import type { ServiceDef } from './service-defs.js'; import type { ServiceCheck } from './verify-services.js'; export type CredentialsStatus = 'configured' | 'missing'; export type VerifyStatus = 'success' | 'failed'; -export interface RegisteredGroupsSummary { - registeredGroups: number; - groupsByAgent: Record; -} - export interface RoleRoutingRequirementsSummary { tribunalRooms: number; activeArbiterTasks: number; } -export interface VerifySummary extends RegisteredGroupsSummary { +export interface VerifySummary extends AssignedRoomsSummary { status: VerifyStatus; servicesSummary: Record; configuredChannels: string[]; channelAuth: Record; tribunalRooms: number; activeArbiterTasks: number; - // Legacy status fields kept for backward-compatible setup output. - codexConfigured: boolean; - reviewConfigured: boolean; + reviewerChannelConfigured: boolean; + arbiterChannelConfigured: boolean; } export function detectCredentials(projectRoot: string): CredentialsStatus { @@ -70,47 +68,18 @@ export function detectChannelAuth( return channelAuth; } -export function loadRegisteredGroupsSummary( - dbPath = path.join(STORE_DIR, 'messages.db'), -): RegisteredGroupsSummary { - let registeredGroups = 0; - const groupsByAgent: Record = {}; - - if (!fs.existsSync(dbPath)) { - return { registeredGroups, groupsByAgent }; - } - - try { - const db = new Database(dbPath, { readonly: true }); - const row = db - .prepare('SELECT COUNT(*) as count FROM registered_groups') - .get() as { count: number }; - registeredGroups = row.count; - - try { - const rows = db - .prepare( - 'SELECT agent_type, COUNT(*) as count FROM registered_groups GROUP BY agent_type', - ) - .all() as { agent_type: string; count: number }[]; - for (const current of rows) { - groupsByAgent[current.agent_type || 'unknown'] = current.count; - } - } catch { - // agent_type column might not exist in older schema - } - - db.close(); - } catch { - // Table might not exist - } - - return { registeredGroups, groupsByAgent }; +export function loadAssignedRoomsSummary( + options?: Parameters[0], +): AssignedRoomsSummary { + return detectRoomRegistrationState(options); } export function loadRoleRoutingRequirementsSummary( - dbPath = path.join(STORE_DIR, 'messages.db'), + options: { + dbPath?: string; + } = {}, ): RoleRoutingRequirementsSummary { + const dbPath = options.dbPath ?? path.join(STORE_DIR, 'messages.db'); let tribunalRooms = 0; let activeArbiterTasks = 0; @@ -126,39 +95,14 @@ export function loadRoleRoutingRequirementsSummary( .prepare( ` SELECT COUNT(*) as count - FROM ( - SELECT chat_jid AS jid - FROM room_settings - WHERE room_mode = 'tribunal' - UNION - SELECT jid - FROM registered_groups - GROUP BY jid - HAVING COUNT(DISTINCT agent_type) > 1 - ) + FROM room_settings + WHERE room_mode = 'tribunal' `, ) .get() as { count: number }; tribunalRooms = roomModeRow.count; } catch { - try { - const fallbackRow = db - .prepare( - ` - SELECT COUNT(*) as count - FROM ( - SELECT jid - FROM registered_groups - GROUP BY jid - HAVING COUNT(DISTINCT agent_type) > 1 - ) - `, - ) - .get() as { count: number }; - tribunalRooms = fallbackRow.count; - } catch { - tribunalRooms = 0; - } + tribunalRooms = 0; } try { @@ -189,8 +133,7 @@ export function buildVerifySummary( serviceDefs: ServiceDef[], credentials: CredentialsStatus, channelAuth: Record, - registeredGroups: number, - groupsByAgent: Record, + roomSummary: AssignedRoomsSummary, options: { tribunalRooms?: number; activeArbiterTasks?: number; @@ -202,20 +145,24 @@ export function buildVerifySummary( (service) => service.status === 'running', ); const hasOwnerCapableChannel = 'discord' in channelAuth; - const codexConfigured = 'discord-review' in channelAuth; - const reviewConfigured = 'discord-arbiter' in channelAuth; + const reviewerChannelConfigured = 'discord-review' in channelAuth; + const arbiterChannelConfigured = 'discord-arbiter' in channelAuth; const tribunalRooms = options.tribunalRooms ?? 0; const activeArbiterTasks = options.activeArbiterTasks ?? 0; - const reviewerConfigured = tribunalRooms === 0 || codexConfigured; - const arbiterConfigured = activeArbiterTasks === 0 || reviewConfigured; + const reviewerRoutingSatisfied = + tribunalRooms === 0 || reviewerChannelConfigured; + const arbiterRoutingSatisfied = + activeArbiterTasks === 0 || arbiterChannelConfigured; const status = allConfiguredServicesRunning && credentials === 'configured' && hasOwnerCapableChannel && - reviewerConfigured && - arbiterConfigured && - registeredGroups > 0 + reviewerRoutingSatisfied && + arbiterRoutingSatisfied && + roomSummary.assignedRooms > 0 && + !roomSummary.legacyRoomMigrationRequired && + !roomSummary.legacyJsonStateMigrationRequired ? 'success' : 'failed'; @@ -231,9 +178,8 @@ export function buildVerifySummary( channelAuth, tribunalRooms, activeArbiterTasks, - registeredGroups, - groupsByAgent, - codexConfigured, - reviewConfigured, + ...roomSummary, + reviewerChannelConfigured, + arbiterChannelConfigured, }; } diff --git a/setup/verify.ts b/setup/verify.ts index 8e71945..e6963fc 100644 --- a/setup/verify.ts +++ b/setup/verify.ts @@ -7,15 +7,18 @@ * * Uses better-sqlite3 directly (no sqlite3 CLI), platform-aware service checks. */ +import path from 'path'; + import { logger } from '../src/logger.js'; import { getServiceManager } from './platform.js'; +import { getLegacyMigrationGuidance } from './room-registration-state.js'; import { getServiceDefs } from './service-defs.js'; import { emitStatus } from './status.js'; import { buildVerifySummary, detectChannelAuth, detectCredentials, - loadRegisteredGroupsSummary, + loadAssignedRoomsSummary, loadRoleRoutingRequirementsSummary, } from './verify-state.js'; import { getServiceChecks } from './verify-services.js'; @@ -40,29 +43,33 @@ export async function run(_args: string[]): Promise { const credentials = detectCredentials(projectRoot); const channelAuth = detectChannelAuth(); - const { registeredGroups, groupsByAgent } = loadRegisteredGroupsSummary(); + const dbPath = path.join(projectRoot, 'store', 'messages.db'); + const roomSummary = loadAssignedRoomsSummary({ projectRoot, dbPath }); const { tribunalRooms, activeArbiterTasks } = - loadRoleRoutingRequirementsSummary(); + loadRoleRoutingRequirementsSummary({ dbPath }); const { status: baseStatus, servicesSummary, configuredChannels, tribunalRooms: detectedTribunalRooms, activeArbiterTasks: detectedActiveArbiterTasks, - codexConfigured, - reviewConfigured, + reviewerChannelConfigured, + arbiterChannelConfigured, } = buildVerifySummary( services, serviceDefs, credentials, channelAuth, - registeredGroups, - groupsByAgent, + roomSummary, { tribunalRooms, activeArbiterTasks, }, ); + const legacyMigrationGuidance = getLegacyMigrationGuidance( + roomSummary, + 'verification', + ); const status = baseStatus; logger.info( @@ -85,10 +92,24 @@ export async function run(_args: string[]): Promise { CHANNEL_AUTH: JSON.stringify(channelAuth), TRIBUNAL_ROOMS: detectedTribunalRooms, ACTIVE_ARBITER_TASKS: detectedActiveArbiterTasks, - REGISTERED_GROUPS: registeredGroups, - GROUPS_BY_AGENT: JSON.stringify(groupsByAgent), - CODEX_CONFIGURED: codexConfigured, - REVIEW_CONFIGURED: reviewConfigured, + ASSIGNED_ROOMS: roomSummary.assignedRooms, + ROOMS_BY_OWNER_AGENT: JSON.stringify(roomSummary.roomsByOwnerAgent), + LEGACY_REGISTERED_GROUP_ROWS: roomSummary.legacyRegisteredGroupRows, + HAS_LEGACY_REGISTERED_GROUPS_JSON: + roomSummary.hasLegacyRegisteredGroupsJson, + LEGACY_ROOM_MIGRATION_REQUIRED: roomSummary.legacyRoomMigrationRequired, + PENDING_LEGACY_JSON_STATE_FILES: + roomSummary.pendingLegacyJsonStateFiles.join(','), + LEGACY_JSON_STATE_MIGRATION_REQUIRED: + roomSummary.legacyJsonStateMigrationRequired, + REVIEWER_CHANNEL_CONFIGURED: reviewerChannelConfigured, + ARBITER_CHANNEL_CONFIGURED: arbiterChannelConfigured, + ...(legacyMigrationGuidance + ? { + ERROR: legacyMigrationGuidance.error, + NEXT_STEP: legacyMigrationGuidance.nextStep, + } + : {}), STATUS: status, LOG: 'logs/setup.log', }); diff --git a/src/db.test.ts b/src/db.test.ts index d55bf6a..f0793b4 100644 --- a/src/db.test.ts +++ b/src/db.test.ts @@ -18,6 +18,9 @@ import { createServiceHandoff, createProducedWorkItem, clearExplicitRoomMode, + claimPairedTurnReservation, + failPairedTurn, + failServiceHandoff, deleteSession, deleteTask, getAllChats, @@ -43,6 +46,9 @@ import { getNewMessages, getPairedProject, getPairedTaskById, + getPairedTurnAttempts, + getPairedTurnById, + getPairedTurnsForTask, getPairedTurnOutputs, getPairedWorkspace, getRouterState, @@ -53,6 +59,9 @@ import { listPairedWorkspacesForTask, markWorkItemDelivered, markWorkItemDeliveryRetry, + markPairedTurnRunning, + releasePairedTaskExecutionLease, + reservePairedTurnReservation, setSession, setRouterState, setExplicitRoomMode, @@ -66,17 +75,32 @@ import { upsertPairedWorkspace, updateTask, } from './db.js'; +import { initializeDatabaseSchema } from './db/bootstrap.js'; +import { + buildPairedTurnAttemptId, + buildPairedTurnAttemptParentId, +} from './db/paired-turn-attempts.js'; +import { + buildLegacyRoomMigrationPlan, + getPendingLegacyRegisteredGroupJids, + insertStoredRoomSettingsFromMigration, + upsertRoomRoleOverride, +} from './db/room-registration.js'; +import { buildPairedTurnIdentity } from './paired-turn-identity.js'; import { ARBITER_AGENT_TYPE, CLAUDE_SERVICE_ID, CODEX_MAIN_SERVICE_ID, CODEX_REVIEW_SERVICE_ID, + SERVICE_ID, SERVICE_SESSION_SCOPE, + normalizeServiceId, } from './config.js'; import { resolveTaskRuntimeIpcPath, resolveTaskSessionsPath, } from './group-folder.js'; +import type { PairedTask } from './types.js'; beforeEach(() => { _initTestDatabase(); @@ -103,6 +127,87 @@ function store(overrides: { }); } +function insertPairedTurnIdentityRow( + database: Database, + args: { + turnId: string; + taskId: string; + taskUpdatedAt: string; + role: 'owner' | 'reviewer' | 'arbiter'; + intentKind: + | 'owner-turn' + | 'reviewer-turn' + | 'arbiter-turn' + | 'owner-follow-up' + | 'finalize-owner-turn'; + createdAt: string; + updatedAt: string; + }, +): void { + database + .prepare( + ` + INSERT INTO paired_turns ( + turn_id, + task_id, + task_updated_at, + role, + intent_kind, + created_at, + updated_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?) + `, + ) + .run( + args.turnId, + args.taskId, + args.taskUpdatedAt, + args.role, + args.intentKind, + args.createdAt, + args.updatedAt, + ); +} + +function migrateLegacyRoomRegistrationsInFile(dbPath: string): { + migratedRooms: number; + migratedRoleOverrides: number; +} { + const migrationDb = new Database(dbPath); + let migratedRooms = 0; + let migratedRoleOverrides = 0; + + try { + initializeDatabaseSchema(migrationDb); + migrationDb.transaction(() => { + const rows = getPendingLegacyRegisteredGroupJids(migrationDb).map( + (jid) => ({ jid }), + ); + + for (const row of rows) { + const plan = buildLegacyRoomMigrationPlan(migrationDb, row.jid); + if (!plan) continue; + const existing = migrationDb + .prepare('SELECT 1 FROM room_settings WHERE chat_jid = ?') + .get(row.jid); + if (!existing) { + insertStoredRoomSettingsFromMigration(migrationDb, plan); + migratedRooms += 1; + } + for (const override of plan.roleOverrides) { + upsertRoomRoleOverride(migrationDb, row.jid, override); + migratedRoleOverrides += 1; + } + } + })(); + } finally { + migrationDb.close(); + } + + return { migratedRooms, migratedRoleOverrides }; +} + // --- storeMessage (NewMessage format) --- describe('storeMessage', () => { @@ -819,7 +924,7 @@ describe('paired task state', () => { expect(getLatestTurnNumber('paired-task-turn-output')).toBe(2); }); - it('normalizes paired task service shadow from persisted role agent types during init', () => { + it('fails init when paired task agent and service metadata conflict', () => { const tempDir = fs.mkdtempSync('/tmp/ejclaw-paired-task-shadow-'); const dbPath = path.join(tempDir, 'messages.db'); const legacyDb = new Database(dbPath); @@ -895,15 +1000,9 @@ describe('paired task state', () => { ); legacyDb.close(); - _initTestDatabaseFromFile(dbPath); - - expect(getPairedTaskById('paired-legacy-1')).toMatchObject({ - owner_service_id: CODEX_MAIN_SERVICE_ID, - reviewer_service_id: CLAUDE_SERVICE_ID, - owner_agent_type: 'codex', - reviewer_agent_type: 'claude-code', - arbiter_agent_type: 'codex', - }); + expect(() => _initTestDatabaseFromFile(dbPath)).toThrow( + /paired_tasks\(paired-legacy-1\): reviewer_agent_type conflicts with reviewer_service_id/, + ); }); it('preserves raw legacy paired task service ids during init when failover created the task', () => { @@ -1113,6 +1212,10 @@ describe('paired task state', () => { ); legacyDb.close(); + expect(migrateLegacyRoomRegistrationsInFile(dbPath)).toEqual({ + migratedRooms: 1, + migratedRoleOverrides: 2, + }); _initTestDatabaseFromFile(dbPath); expect(getPairedTaskById('paired-legacy-groups')).toMatchObject({ @@ -1239,7 +1342,7 @@ describe('paired task state', () => { }); }); - it('preserves task-level role metadata even when current room settings differ', () => { + it('fails init when stored paired task metadata conflicts with stored service ids even if room settings differ', () => { const tempDir = fs.mkdtempSync('/tmp/ejclaw-paired-task-ssot-'); const dbPath = path.join(tempDir, 'messages.db'); const legacyDb = new Database(dbPath); @@ -1346,21 +1449,12 @@ describe('paired task state', () => { ); legacyDb.close(); - _initTestDatabaseFromFile(dbPath); - - expect(getStoredRoomSettings('dc:task-ssot')).toMatchObject({ - ownerAgentType: 'codex', - }); - expect(getPairedTaskById('paired-task-ssot')).toMatchObject({ - 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: 'codex', - }); + expect(() => _initTestDatabaseFromFile(dbPath)).toThrow( + /paired_tasks\(paired-task-ssot\): owner_agent_type conflicts with owner_service_id/, + ); }); - it('preserves explicit room owner trigger and agent type during init and uses them for null task fallback', () => { + it('preserves explicit room trigger during init without rewriting task agent metadata from room settings', () => { const tempDir = fs.mkdtempSync('/tmp/ejclaw-room-settings-ssot-'); const dbPath = path.join(tempDir, 'messages.db'); const legacyDb = new Database(dbPath); @@ -1524,6 +1618,10 @@ describe('paired task state', () => { ); legacyDb.close(); + expect(migrateLegacyRoomRegistrationsInFile(dbPath)).toEqual({ + migratedRooms: 0, + migratedRoleOverrides: 2, + }); _initTestDatabaseFromFile(dbPath); expect(getStoredRoomSettings('dc:explicit-owner')).toMatchObject({ @@ -1535,8 +1633,8 @@ describe('paired task state', () => { expect(getPairedTaskById('paired-explicit-owner')).toMatchObject({ owner_service_id: CODEX_REVIEW_SERVICE_ID, reviewer_service_id: CODEX_MAIN_SERVICE_ID, - owner_agent_type: 'claude-code', - reviewer_agent_type: 'claude-code', + owner_agent_type: 'codex', + reviewer_agent_type: 'codex', arbiter_agent_type: ARBITER_AGENT_TYPE ?? null, }); }); @@ -1659,6 +1757,10 @@ describe('paired task state', () => { ); legacyDb.close(); + expect(migrateLegacyRoomRegistrationsInFile(dbPath)).toEqual({ + migratedRooms: 0, + migratedRoleOverrides: 2, + }); _initTestDatabaseFromFile(dbPath); expect(getStoredRoomSettings('dc:explicit-trigger-only')).toMatchObject({ @@ -1807,7 +1909,7 @@ describe('room assignment writes', () => { expect(getRegisteredAgentTypesForJid('tg:-1001')).toEqual(['claude-code']); }); - it('materializes tribunal capability rows while serving metadata from room_settings', () => { + it('serves tribunal capability views from room_settings without legacy projection rows', () => { assignRoom('dc:assigned-room', { name: 'Assigned Room', roomMode: 'tribunal', @@ -1836,7 +1938,7 @@ describe('room assignment writes', () => { }); }); - it('updates room_settings-backed metadata across tribunal projection rows', () => { + it('updates room_settings-backed metadata across tribunal capability views', () => { assignRoom('dc:projection-room', { name: 'Projection Room', roomMode: 'tribunal', @@ -1868,6 +1970,79 @@ describe('room assignment writes', () => { ); }); + it('does not carry an owner override config onto a different owner agent type', () => { + assignRoom('dc:owner-switch', { + name: 'Owner Switch', + roomMode: 'tribunal', + ownerAgentType: 'codex', + folder: 'owner-switch', + ownerAgentConfig: { + codexModel: 'gpt-5-codex', + }, + }); + + assignRoom('dc:owner-switch', { + name: 'Owner Switch', + roomMode: 'tribunal', + ownerAgentType: 'claude-code', + folder: 'owner-switch', + }); + + expect( + getAllRegisteredGroups('claude-code')['dc:owner-switch'], + ).toMatchObject({ + agentType: 'claude-code', + }); + expect( + getAllRegisteredGroups('claude-code')['dc:owner-switch']?.agentConfig, + ).toBeUndefined(); + expect(getAllRegisteredGroups('codex')['dc:owner-switch']).toMatchObject({ + agentType: 'codex', + }); + expect( + getAllRegisteredGroups('codex')['dc:owner-switch']?.agentConfig, + ).toBeUndefined(); + }); + + it('does not write legacy registered_groups rows when assigning a canonical room', () => { + const tempDir = fs.mkdtempSync('/tmp/ejclaw-room-assign-canonical-'); + const dbPath = path.join(tempDir, 'messages.db'); + + _initTestDatabaseFromFile(dbPath); + assignRoom('dc:assign-no-projection', { + name: 'Assign No Projection', + roomMode: 'tribunal', + ownerAgentType: 'codex', + folder: 'assign-no-projection', + }); + + const rawDb = new Database(dbPath, { readonly: true }); + const projectionRows = rawDb + .prepare( + `SELECT agent_type + FROM registered_groups + WHERE jid = ?`, + ) + .all('dc:assign-no-projection'); + rawDb.close(); + + expect(projectionRows).toEqual([]); + expect( + getAllRegisteredGroups('claude-code')['dc:assign-no-projection'], + ).toMatchObject({ + name: 'Assign No Projection', + folder: 'assign-no-projection', + agentType: 'claude-code', + }); + expect( + getAllRegisteredGroups('codex')['dc:assign-no-projection'], + ).toMatchObject({ + name: 'Assign No Projection', + folder: 'assign-no-projection', + agentType: 'codex', + }); + }); + it('recreates inferred room_settings when renaming a legacy projection-only room', () => { _setRegisteredGroupForTests('dc:legacy-rename', { name: 'Legacy Rename', @@ -1908,7 +2083,7 @@ describe('room assignment writes', () => { }); }); - it('materializes inferred room_settings from legacy registered_groups rows during init', () => { + it('requires explicit migration before init when only legacy registered_groups rows exist', () => { const tempDir = fs.mkdtempSync('/tmp/ejclaw-legacy-room-settings-init-'); const dbPath = path.join(tempDir, 'messages.db'); const legacyDb = new Database(dbPath); @@ -1962,6 +2137,13 @@ describe('room assignment writes', () => { ); legacyDb.close(); + expect(() => _initTestDatabaseFromFile(dbPath)).toThrow( + /Legacy room migration required before startup/, + ); + expect(migrateLegacyRoomRegistrationsInFile(dbPath)).toEqual({ + migratedRooms: 1, + migratedRoleOverrides: 2, + }); _initTestDatabaseFromFile(dbPath); expect(getStoredRoomSettings('dc:legacy-sql')).toMatchObject({ @@ -1976,7 +2158,7 @@ describe('room assignment writes', () => { expect(getEffectiveRuntimeRoomMode('dc:legacy-sql')).toBe('tribunal'); }); - it('fails init when legacy registered_groups rows conflict on room-level metadata', () => { + it('fails explicit migration 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); @@ -2031,10 +2213,176 @@ describe('room assignment writes', () => { legacyDb.close(); expect(() => _initTestDatabaseFromFile(dbPath)).toThrow( + /Legacy room migration required before startup/, + ); + expect(() => migrateLegacyRoomRegistrationsInFile(dbPath)).toThrow( /Conflicting room-level registered_groups metadata/, ); }); + it('requires explicit migration before init when room_settings conflicts with legacy room-level metadata', () => { + const tempDir = fs.mkdtempSync('/tmp/ejclaw-legacy-room-mixed-conflict-'); + const dbPath = path.join(tempDir, 'messages.db'); + const legacyDb = new Database(dbPath); + + legacyDb.exec(` + CREATE TABLE room_settings ( + chat_jid TEXT PRIMARY KEY, + room_mode TEXT NOT NULL DEFAULT 'single', + mode_source TEXT NOT NULL DEFAULT 'explicit', + name TEXT, + folder TEXT, + trigger_pattern TEXT, + requires_trigger INTEGER, + is_main INTEGER, + owner_agent_type TEXT, + work_dir TEXT, + updated_at TEXT + ); + + CREATE TABLE room_role_overrides ( + chat_jid TEXT NOT NULL, + role TEXT NOT NULL, + agent_type TEXT NOT NULL, + agent_config_json TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (chat_jid, role) + ); + + 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) + ); + `); + + legacyDb + .prepare( + `INSERT INTO room_settings ( + chat_jid, + room_mode, + mode_source, + name, + folder, + trigger_pattern, + requires_trigger, + is_main, + owner_agent_type, + work_dir, + updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + 'dc:mixed-conflict', + 'single', + 'explicit', + 'Canonical Room', + 'canonical-folder', + '@Andy', + 1, + 0, + 'codex', + null, + '2026-04-08T00:00:00.000Z', + ); + legacyDb + .prepare( + `INSERT INTO room_role_overrides ( + chat_jid, + role, + agent_type, + agent_config_json, + created_at, + updated_at + ) VALUES (?, ?, ?, ?, ?, ?)`, + ) + .run( + 'dc:mixed-conflict', + 'owner', + 'codex', + null, + '2026-04-08T00:00:00.000Z', + '2026-04-08T00:00:00.000Z', + ); + + 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:mixed-conflict', + 'Legacy Room', + 'legacy-folder', + '@Codex', + '2026-04-08T00:00:00.000Z', + 'codex', + ); + legacyDb.close(); + + const pendingDb = new Database(dbPath, { readonly: true }); + expect(getPendingLegacyRegisteredGroupJids(pendingDb)).toEqual([ + 'dc:mixed-conflict', + ]); + pendingDb.close(); + expect(() => _initTestDatabaseFromFile(dbPath)).toThrow( + /Legacy room migration required before startup/, + ); + }); + + it('fails init when legacy router_state DB keys remain without canonical keys', () => { + const tempDir = fs.mkdtempSync('/tmp/ejclaw-legacy-router-state-db-'); + const dbPath = path.join(tempDir, 'messages.db'); + const legacyDb = new Database(dbPath); + + initializeDatabaseSchema(legacyDb); + legacyDb + .prepare('INSERT OR REPLACE INTO router_state (key, value) VALUES (?, ?)') + .run('last_timestamp', '1234'); + legacyDb + .prepare('INSERT OR REPLACE INTO router_state (key, value) VALUES (?, ?)') + .run('last_agent_timestamp', '{"dc:room":"5678"}'); + legacyDb.close(); + + expect(() => _initTestDatabaseFromFile(dbPath)).toThrow( + /Legacy router_state DB migration required before startup \(keys=last_agent_timestamp,last_timestamp\)/, + ); + + const rawDb = new Database(dbPath, { readonly: true }); + expect( + rawDb + .prepare( + `SELECT key, value + FROM router_state + ORDER BY key`, + ) + .all(), + ).toEqual([ + { key: 'last_agent_timestamp', value: '{"dc:room":"5678"}' }, + { key: 'last_timestamp', value: '1234' }, + ]); + rawDb.close(); + }); + 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'); @@ -2118,7 +2466,7 @@ describe('room assignment writes', () => { ) .run( 'dc:ssot-room', - 'Stale Projection', + 'SSOT Room', 'ssot-room', '@Codex', '2026-04-08T00:00:00.000Z', @@ -2146,7 +2494,7 @@ describe('room assignment writes', () => { ) .run( 'dc:ssot-room', - 'Stale Projection', + 'SSOT Room', 'ssot-room', '@Claude', '2026-04-08T00:00:00.000Z', @@ -2158,6 +2506,10 @@ describe('room assignment writes', () => { ); legacyDb.close(); + expect(migrateLegacyRoomRegistrationsInFile(dbPath)).toEqual({ + migratedRooms: 0, + migratedRoleOverrides: 1, + }); _initTestDatabaseFromFile(dbPath); expect(getRegisteredGroup('dc:ssot-room')).toMatchObject({ @@ -2171,7 +2523,7 @@ describe('room assignment writes', () => { expect(getRegisteredAgentTypesForJid('dc:ssot-room')).toEqual(['codex']); }); - it('re-materializes explicit room_settings writes back into the projection rows', () => { + it('clears stale legacy registered_groups rows after canonical room metadata changes', () => { const tempDir = fs.mkdtempSync('/tmp/ejclaw-room-writeback-'); const dbPath = path.join(tempDir, 'messages.db'); const legacyDb = new Database(dbPath); @@ -2254,7 +2606,7 @@ describe('room assignment writes', () => { insertProjection.run( 'dc:ssot-writeback', - 'Stale Projection', + 'Explicit Writeback', 'ssot-writeback', '@Codex', '2026-04-08T00:00:00.000Z', @@ -2266,7 +2618,7 @@ describe('room assignment writes', () => { ); insertProjection.run( 'dc:ssot-writeback', - 'Stale Projection', + 'Explicit Writeback', 'ssot-writeback', '@Claude', '2026-04-08T00:00:00.000Z', @@ -2278,6 +2630,10 @@ describe('room assignment writes', () => { ); legacyDb.close(); + expect(migrateLegacyRoomRegistrationsInFile(dbPath)).toEqual({ + migratedRooms: 0, + migratedRoleOverrides: 1, + }); _initTestDatabaseFromFile(dbPath); updateRegisteredGroupName('dc:ssot-writeback', 'SSOT Writeback Renamed'); @@ -2291,7 +2647,7 @@ describe('room assignment writes', () => { }); const rawDb = new Database(dbPath); - const projectionRows = rawDb + let projectionRows = rawDb .prepare( `SELECT agent_type, name FROM registered_groups @@ -2304,12 +2660,20 @@ describe('room assignment writes', () => { }>; rawDb.close(); - expect(projectionRows).toEqual([ - { - agent_type: 'codex', - name: 'SSOT Writeback Renamed', - }, - ]); + expect(projectionRows).toEqual([]); + + _initTestDatabaseFromFile(dbPath); + + expect(getStoredRoomSettings('dc:ssot-writeback')).toMatchObject({ + chatJid: 'dc:ssot-writeback', + roomMode: 'single', + modeSource: 'explicit', + name: 'SSOT Writeback Renamed', + ownerAgentType: 'codex', + }); + const pendingDb = new Database(dbPath); + expect(getPendingLegacyRegisteredGroupJids(pendingDb)).toEqual([]); + pendingDb.close(); clearExplicitRoomMode('dc:ssot-writeback'); @@ -2834,10 +3198,146 @@ describe('service handoff completion', () => { ]); }); + it('fails startup when stored handoff agent metadata conflicts with service ids', () => { + const tempDir = fs.mkdtempSync('/tmp/ejclaw-handoff-metadata-conflict-'); + 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, + 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 + .prepare( + `INSERT INTO service_handoffs ( + chat_jid, + group_folder, + source_service_id, + target_service_id, + source_role, + source_agent_type, + target_role, + target_agent_type, + prompt, + status, + intended_role, + created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + 'dc:handoff-conflict', + 'handoff-conflict', + CODEX_REVIEW_SERVICE_ID, + CLAUDE_SERVICE_ID, + 'reviewer', + 'claude-code', + 'reviewer', + 'codex', + 'conflicting handoff metadata', + 'pending', + 'reviewer', + '2026-04-10T00:00:00.000Z', + ); + legacyDb.close(); + + expect(() => _initTestDatabaseFromFile(dbPath)).toThrow( + /source_agent_type conflicts with source_service_id/, + ); + }); + + it('fails startup when stored handoff role metadata conflicts with service shadows', () => { + const tempDir = fs.mkdtempSync('/tmp/ejclaw-handoff-role-shadow-conflict-'); + 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, + 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 + .prepare( + `INSERT INTO service_handoffs ( + chat_jid, + group_folder, + source_service_id, + target_service_id, + source_role, + source_agent_type, + target_role, + target_agent_type, + prompt, + status, + intended_role, + created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + 'dc:handoff-role-conflict', + 'handoff-role-conflict', + CLAUDE_SERVICE_ID, + CODEX_MAIN_SERVICE_ID, + 'owner', + 'claude-code', + 'reviewer', + 'codex', + 'conflicting handoff role shadow', + 'pending', + 'reviewer', + '2026-04-10T00:00:00.000Z', + ); + legacyDb.close(); + + expect(() => _initTestDatabaseFromFile(dbPath)).toThrow( + /target_role conflicts with target_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', + paired_task_id: 'task-stored-reviewer-handoff', + paired_task_updated_at: '2026-04-10T00:00:00.000Z', + turn_intent_kind: 'reviewer-turn', + turn_role: 'reviewer', source_service_id: CLAUDE_SERVICE_ID, target_service_id: 'stale-reviewer-shadow', source_role: 'owner', @@ -2849,9 +3349,35 @@ describe('service handoff completion', () => { }); expect(handoff.target_service_id).toBe('stale-reviewer-shadow'); + expect(handoff.turn_id).toBe( + 'task-stored-reviewer-handoff:2026-04-10T00:00:00.000Z:reviewer-turn', + ); + expect(handoff.turn_attempt_no).toBe(1); + expect(handoff.turn_role).toBe('reviewer'); + expect( + getPairedTurnById( + 'task-stored-reviewer-handoff:2026-04-10T00:00:00.000Z:reviewer-turn', + ), + ).toMatchObject({ + turn_id: + 'task-stored-reviewer-handoff:2026-04-10T00:00:00.000Z:reviewer-turn', + task_id: 'task-stored-reviewer-handoff', + role: 'reviewer', + intent_kind: 'reviewer-turn', + state: 'delegated', + executor_service_id: 'stale-reviewer-shadow', + executor_agent_type: 'codex', + }); expect(getPendingServiceHandoffs('stale-reviewer-shadow')).toEqual([ expect.objectContaining({ id: handoff.id, + paired_task_id: 'task-stored-reviewer-handoff', + paired_task_updated_at: '2026-04-10T00:00:00.000Z', + turn_id: + 'task-stored-reviewer-handoff:2026-04-10T00:00:00.000Z:reviewer-turn', + turn_attempt_no: 1, + turn_intent_kind: 'reviewer-turn', + turn_role: 'reviewer', source_service_id: CLAUDE_SERVICE_ID, target_service_id: 'stale-reviewer-shadow', source_role: 'owner', @@ -2859,6 +3385,4474 @@ describe('service handoff completion', () => { }), ]); }); + + it('marks a delegated logical turn failed when its handoff fails', () => { + const handoff = createServiceHandoff({ + chat_jid: 'dc:handoff-failed-turn', + group_folder: 'handoff-failed-turn', + paired_task_id: 'task-failed-reviewer-handoff', + paired_task_updated_at: '2026-04-10T00:00:00.000Z', + turn_intent_kind: 'reviewer-turn', + turn_role: 'reviewer', + source_service_id: CLAUDE_SERVICE_ID, + target_service_id: CODEX_REVIEW_SERVICE_ID, + source_role: 'owner', + target_role: 'reviewer', + source_agent_type: 'claude-code', + target_agent_type: 'codex', + prompt: 'failed reviewer handoff', + intended_role: 'reviewer', + }); + + failServiceHandoff(handoff.id, 'Group not registered on target service'); + + expect( + getPairedTurnById( + 'task-failed-reviewer-handoff:2026-04-10T00:00:00.000Z:reviewer-turn', + ), + ).toMatchObject({ + turn_id: + 'task-failed-reviewer-handoff:2026-04-10T00:00:00.000Z:reviewer-turn', + state: 'failed', + last_error: 'Group not registered on target service', + }); + expect(getPendingServiceHandoffs(CODEX_REVIEW_SERVICE_ID)).toEqual([]); + }); + + it('records queued and running logical turn state across reservation and lease claims', () => { + const task: PairedTask = { + id: 'task-paired-turn-state', + chat_jid: 'group@test', + group_folder: 'test-group', + 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: null, + title: null, + source_ref: 'HEAD', + plan_notes: null, + review_requested_at: '2026-04-10T00:00:00.000Z', + round_trip_count: 1, + status: 'review_ready' as const, + arbiter_verdict: null, + arbiter_requested_at: null, + completion_reason: null, + created_at: '2026-04-10T00:00:00.000Z', + updated_at: '2026-04-10T00:00:00.000Z', + }; + createPairedTask(task); + + expect( + reservePairedTurnReservation({ + chatJid: task.chat_jid, + taskId: task.id, + taskStatus: task.status, + roundTripCount: task.round_trip_count, + taskUpdatedAt: task.updated_at, + intentKind: 'reviewer-turn', + runId: 'run-queued-turn', + }), + ).toBe(true); + expect( + getPairedTurnById(`${task.id}:${task.updated_at}:reviewer-turn`), + ).toMatchObject({ + state: 'queued', + attempt_no: 0, + executor_service_id: null, + }); + + expect( + claimPairedTurnReservation({ + chatJid: task.chat_jid, + taskId: task.id, + taskStatus: task.status, + roundTripCount: task.round_trip_count, + taskUpdatedAt: task.updated_at, + intentKind: 'reviewer-turn', + runId: 'run-running-turn', + }), + ).toBe(true); + + expect( + getPairedTurnById(`${task.id}:${task.updated_at}:reviewer-turn`), + ).toMatchObject({ + state: 'running', + attempt_no: 1, + executor_service_id: normalizeServiceId(SERVICE_ID), + }); + expect(getPairedTurnsForTask(task.id)).toHaveLength(1); + }); + + it('does not create current-state shadow columns in fresh paired_turns schema', () => { + const database = new Database(':memory:'); + + try { + initializeDatabaseSchema(database); + + const pairedTurnColumns = database + .prepare(`PRAGMA table_info(paired_turns)`) + .all() as Array<{ name: string }>; + + expect( + pairedTurnColumns.some( + (column) => + column.name === 'state' || + column.name === 'attempt_no' || + column.name === 'executor_service_id' || + column.name === 'executor_agent_type' || + column.name === 'completed_at' || + column.name === 'last_error', + ), + ).toBe(false); + } finally { + database.close(); + } + }); + + it('records execution attempt history across delegated failure and retry', () => { + const task: PairedTask = { + id: 'task-paired-turn-attempt-history', + chat_jid: 'group@test', + group_folder: 'test-group', + 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: null, + title: null, + source_ref: 'HEAD', + plan_notes: null, + review_requested_at: '2026-04-10T00:00:00.000Z', + round_trip_count: 1, + status: 'review_ready' as const, + arbiter_verdict: null, + arbiter_requested_at: null, + completion_reason: null, + created_at: '2026-04-10T00:00:00.000Z', + updated_at: '2026-04-10T00:00:00.000Z', + }; + createPairedTask(task); + + expect( + reservePairedTurnReservation({ + chatJid: task.chat_jid, + taskId: task.id, + taskStatus: task.status, + roundTripCount: task.round_trip_count, + taskUpdatedAt: task.updated_at, + intentKind: 'reviewer-turn', + runId: 'run-attempt-history-queued-1', + }), + ).toBe(true); + expect( + claimPairedTurnReservation({ + chatJid: task.chat_jid, + taskId: task.id, + taskStatus: task.status, + roundTripCount: task.round_trip_count, + taskUpdatedAt: task.updated_at, + intentKind: 'reviewer-turn', + runId: 'run-attempt-history-running-1', + }), + ).toBe(true); + + const handoff = createServiceHandoff({ + chat_jid: task.chat_jid, + group_folder: task.group_folder, + paired_task_id: task.id, + paired_task_updated_at: task.updated_at, + turn_intent_kind: 'reviewer-turn', + turn_role: 'reviewer', + source_service_id: CLAUDE_SERVICE_ID, + target_service_id: CODEX_REVIEW_SERVICE_ID, + source_role: 'owner', + target_role: 'reviewer', + source_agent_type: 'claude-code', + target_agent_type: 'codex', + prompt: 'retry reviewer via delegated handoff', + intended_role: 'reviewer', + }); + + failServiceHandoff(handoff.id, 'delegated reviewer handoff failed'); + releasePairedTaskExecutionLease({ + taskId: task.id, + runId: 'run-attempt-history-running-1', + }); + + expect( + reservePairedTurnReservation({ + chatJid: task.chat_jid, + taskId: task.id, + taskStatus: task.status, + roundTripCount: task.round_trip_count, + taskUpdatedAt: task.updated_at, + intentKind: 'reviewer-turn', + runId: 'run-attempt-history-queued-2', + }), + ).toBe(true); + expect( + claimPairedTurnReservation({ + chatJid: task.chat_jid, + taskId: task.id, + taskStatus: task.status, + roundTripCount: task.round_trip_count, + taskUpdatedAt: task.updated_at, + intentKind: 'reviewer-turn', + runId: 'run-attempt-history-running-2', + }), + ).toBe(true); + + expect( + getPairedTurnAttempts(`${task.id}:${task.updated_at}:reviewer-turn`), + ).toMatchObject([ + { + attempt_no: 1, + parent_handoff_id: null, + continuation_handoff_id: handoff.id, + task_id: task.id, + task_updated_at: task.updated_at, + role: 'reviewer', + intent_kind: 'reviewer-turn', + state: 'failed', + executor_service_id: CODEX_REVIEW_SERVICE_ID, + executor_agent_type: 'codex', + last_error: 'delegated reviewer handoff failed', + }, + { + attempt_no: 2, + parent_handoff_id: handoff.id, + task_id: task.id, + task_updated_at: task.updated_at, + role: 'reviewer', + intent_kind: 'reviewer-turn', + state: 'running', + executor_service_id: normalizeServiceId(SERVICE_ID), + executor_agent_type: + normalizeServiceId(SERVICE_ID) === CLAUDE_SERVICE_ID + ? 'claude-code' + : 'codex', + last_error: null, + }, + ]); + }); + + it('reopens a completed reservation from the latest failed attempt even when paired_turns state is stale', () => { + const tempDir = fs.mkdtempSync('/tmp/ejclaw-paired-turn-failed-reopen-'); + const dbPath = path.join(tempDir, 'messages.db'); + + try { + _initTestDatabaseFromFile(dbPath); + + const task: PairedTask = { + id: 'task-paired-turn-failed-reopen', + chat_jid: 'group@test', + group_folder: 'test-group', + 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: null, + title: null, + source_ref: 'HEAD', + plan_notes: null, + review_requested_at: '2026-04-10T00:00:00.000Z', + round_trip_count: 1, + status: 'review_ready' as const, + arbiter_verdict: null, + arbiter_requested_at: null, + completion_reason: null, + created_at: '2026-04-10T00:00:00.000Z', + updated_at: '2026-04-10T00:00:00.000Z', + }; + createPairedTask(task); + + expect( + reservePairedTurnReservation({ + chatJid: task.chat_jid, + taskId: task.id, + taskStatus: task.status, + roundTripCount: task.round_trip_count, + taskUpdatedAt: task.updated_at, + intentKind: 'reviewer-turn', + runId: 'run-failed-reopen-queued-1', + }), + ).toBe(true); + expect( + claimPairedTurnReservation({ + chatJid: task.chat_jid, + taskId: task.id, + taskStatus: task.status, + roundTripCount: task.round_trip_count, + taskUpdatedAt: task.updated_at, + intentKind: 'reviewer-turn', + runId: 'run-failed-reopen-running-1', + }), + ).toBe(true); + + const turnIdentity = buildPairedTurnIdentity({ + taskId: task.id, + taskUpdatedAt: task.updated_at, + intentKind: 'reviewer-turn', + role: 'reviewer', + }); + failPairedTurn({ + turnIdentity, + error: 'failed reviewer attempt', + }); + releasePairedTaskExecutionLease({ + taskId: task.id, + runId: 'run-failed-reopen-running-1', + }); + + const rawDb = new Database(dbPath); + rawDb.exec( + `ALTER TABLE paired_turns ADD COLUMN state TEXT DEFAULT 'queued'`, + ); + rawDb.exec(`ALTER TABLE paired_turns ADD COLUMN last_error TEXT`); + rawDb + .prepare( + ` + UPDATE paired_turns + SET state = 'queued', + last_error = NULL + WHERE turn_id = ? + `, + ) + .run(turnIdentity.turnId); + rawDb.close(); + + expect( + reservePairedTurnReservation({ + chatJid: task.chat_jid, + taskId: task.id, + taskStatus: task.status, + roundTripCount: task.round_trip_count, + taskUpdatedAt: task.updated_at, + intentKind: 'reviewer-turn', + runId: 'run-failed-reopen-queued-2', + }), + ).toBe(true); + expect( + claimPairedTurnReservation({ + chatJid: task.chat_jid, + taskId: task.id, + taskStatus: task.status, + roundTripCount: task.round_trip_count, + taskUpdatedAt: task.updated_at, + intentKind: 'reviewer-turn', + runId: 'run-failed-reopen-running-2', + }), + ).toBe(true); + + expect(getPairedTurnById(turnIdentity.turnId)).toMatchObject({ + state: 'running', + attempt_no: 2, + }); + expect(getPairedTurnAttempts(turnIdentity.turnId)).toMatchObject([ + { + attempt_no: 1, + state: 'failed', + last_error: 'failed reviewer attempt', + }, + { + attempt_no: 2, + state: 'running', + active_run_id: 'run-failed-reopen-running-2', + }, + ]); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + _initTestDatabase(); + } + }); + + it('keeps delegated continuation on attempt 1 even when paired_turns attempt_no is stale', () => { + const tempDir = fs.mkdtempSync( + '/tmp/ejclaw-paired-turn-attempt-cache-drift-', + ); + const dbPath = path.join(tempDir, 'messages.db'); + + try { + _initTestDatabaseFromFile(dbPath); + + const task: PairedTask = { + id: 'task-paired-turn-attempt-cache-drift', + chat_jid: 'group@test', + group_folder: 'test-group', + 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: null, + title: null, + source_ref: 'HEAD', + plan_notes: null, + review_requested_at: '2026-04-10T00:00:00.000Z', + round_trip_count: 1, + status: 'review_ready' as const, + arbiter_verdict: null, + arbiter_requested_at: null, + completion_reason: null, + created_at: '2026-04-10T00:00:00.000Z', + updated_at: '2026-04-10T00:00:00.000Z', + }; + createPairedTask(task); + + expect( + reservePairedTurnReservation({ + chatJid: task.chat_jid, + taskId: task.id, + taskStatus: task.status, + roundTripCount: task.round_trip_count, + taskUpdatedAt: task.updated_at, + intentKind: 'reviewer-turn', + runId: 'run-attempt-cache-drift-queued-1', + }), + ).toBe(true); + expect( + claimPairedTurnReservation({ + chatJid: task.chat_jid, + taskId: task.id, + taskStatus: task.status, + roundTripCount: task.round_trip_count, + taskUpdatedAt: task.updated_at, + intentKind: 'reviewer-turn', + runId: 'run-attempt-cache-drift-running-1', + }), + ).toBe(true); + + const turnId = `${task.id}:${task.updated_at}:reviewer-turn`; + const rawDb = new Database(dbPath); + rawDb.exec( + `ALTER TABLE paired_turns ADD COLUMN attempt_no INTEGER NOT NULL DEFAULT 0`, + ); + rawDb + .prepare( + ` + UPDATE paired_turns + SET attempt_no = 99 + WHERE turn_id = ? + `, + ) + .run(turnId); + rawDb.close(); + + const handoff = createServiceHandoff({ + chat_jid: task.chat_jid, + group_folder: task.group_folder, + paired_task_id: task.id, + paired_task_updated_at: task.updated_at, + turn_intent_kind: 'reviewer-turn', + turn_role: 'reviewer', + source_service_id: CLAUDE_SERVICE_ID, + target_service_id: CODEX_REVIEW_SERVICE_ID, + source_role: 'owner', + target_role: 'reviewer', + source_agent_type: 'claude-code', + target_agent_type: 'codex', + prompt: 'delegate reviewer with stale aggregate attempt cache', + intended_role: 'reviewer', + }); + + expect(handoff.turn_attempt_no).toBe(1); + expect(getPairedTurnById(turnId)).toMatchObject({ + state: 'delegated', + attempt_no: 1, + executor_service_id: CODEX_REVIEW_SERVICE_ID, + }); + expect(getPairedTurnAttempts(turnId)).toMatchObject([ + { + attempt_no: 1, + continuation_handoff_id: handoff.id, + state: 'delegated', + executor_service_id: CODEX_REVIEW_SERVICE_ID, + executor_agent_type: 'codex', + }, + ]); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + _initTestDatabase(); + } + }); + + it('drops legacy next_parent_handoff_id scratch state on re-init and keeps retry lineage on attempt rows', () => { + const tempDir = fs.mkdtempSync('/tmp/ejclaw-attempt-parent-lineage-'); + const dbPath = path.join(tempDir, 'messages.db'); + + _initTestDatabaseFromFile(dbPath); + + const task: PairedTask = { + id: 'task-attempt-parent-lineage', + chat_jid: 'group@test', + group_folder: 'test-group', + 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: null, + title: null, + source_ref: 'HEAD', + plan_notes: null, + review_requested_at: '2026-04-10T00:00:00.000Z', + round_trip_count: 1, + status: 'review_ready' as const, + arbiter_verdict: null, + arbiter_requested_at: null, + completion_reason: null, + created_at: '2026-04-10T00:00:00.000Z', + updated_at: '2026-04-10T00:00:00.000Z', + }; + createPairedTask(task); + + expect( + reservePairedTurnReservation({ + chatJid: task.chat_jid, + taskId: task.id, + taskStatus: task.status, + roundTripCount: task.round_trip_count, + taskUpdatedAt: task.updated_at, + intentKind: 'reviewer-turn', + runId: 'run-attempt-parent-lineage-queued-1', + }), + ).toBe(true); + expect( + claimPairedTurnReservation({ + chatJid: task.chat_jid, + taskId: task.id, + taskStatus: task.status, + roundTripCount: task.round_trip_count, + taskUpdatedAt: task.updated_at, + intentKind: 'reviewer-turn', + runId: 'run-attempt-parent-lineage-running-1', + }), + ).toBe(true); + + const handoff = createServiceHandoff({ + chat_jid: task.chat_jid, + group_folder: task.group_folder, + paired_task_id: task.id, + paired_task_updated_at: task.updated_at, + turn_intent_kind: 'reviewer-turn', + turn_role: 'reviewer', + source_service_id: CLAUDE_SERVICE_ID, + target_service_id: CODEX_REVIEW_SERVICE_ID, + source_role: 'owner', + target_role: 'reviewer', + source_agent_type: 'claude-code', + target_agent_type: 'codex', + prompt: 'derive retry parent from previous attempt row', + intended_role: 'reviewer', + }); + + failServiceHandoff(handoff.id, 'delegated reviewer handoff failed'); + releasePairedTaskExecutionLease({ + taskId: task.id, + runId: 'run-attempt-parent-lineage-running-1', + }); + + const turnId = `${task.id}:${task.updated_at}:reviewer-turn`; + const rawDb = new Database(dbPath); + rawDb.exec( + `ALTER TABLE paired_turns ADD COLUMN next_parent_handoff_id INTEGER`, + ); + rawDb + .prepare( + ` + UPDATE paired_turns + SET next_parent_handoff_id = ? + WHERE turn_id = ? + `, + ) + .run(handoff.id + 9999, turnId); + rawDb.close(); + + _initTestDatabaseFromFile(dbPath); + + const rebuiltDb = new Database(dbPath); + const pairedTurnColumns = rebuiltDb + .prepare(`PRAGMA table_info(paired_turns)`) + .all() as Array<{ name: string }>; + rebuiltDb.close(); + + expect( + pairedTurnColumns.some( + (column) => column.name === 'next_parent_handoff_id', + ), + ).toBe(false); + + expect( + reservePairedTurnReservation({ + chatJid: task.chat_jid, + taskId: task.id, + taskStatus: task.status, + roundTripCount: task.round_trip_count, + taskUpdatedAt: task.updated_at, + intentKind: 'reviewer-turn', + runId: 'run-attempt-parent-lineage-queued-2', + }), + ).toBe(true); + expect( + claimPairedTurnReservation({ + chatJid: task.chat_jid, + taskId: task.id, + taskStatus: task.status, + roundTripCount: task.round_trip_count, + taskUpdatedAt: task.updated_at, + intentKind: 'reviewer-turn', + runId: 'run-attempt-parent-lineage-running-2', + }), + ).toBe(true); + + expect(getPairedTurnAttempts(turnId)).toMatchObject([ + { + attempt_no: 1, + continuation_handoff_id: handoff.id, + state: 'failed', + }, + { + attempt_no: 2, + parent_handoff_id: handoff.id, + state: 'running', + }, + ]); + }); + + it('keeps attempt 1 when a delegated handoff continues on the target executor', () => { + const handoff = createServiceHandoff({ + chat_jid: 'dc:handoff-attempt-continuation', + group_folder: 'handoff-attempt-continuation', + paired_task_id: 'task-handoff-attempt-continuation', + paired_task_updated_at: '2026-04-10T00:00:00.000Z', + turn_intent_kind: 'reviewer-turn', + turn_role: 'reviewer', + source_service_id: CLAUDE_SERVICE_ID, + target_service_id: CODEX_REVIEW_SERVICE_ID, + source_role: 'owner', + target_role: 'reviewer', + source_agent_type: 'claude-code', + target_agent_type: 'codex', + prompt: 'continue delegated reviewer handoff', + intended_role: 'reviewer', + }); + + expect(handoff.turn_id).toBe( + 'task-handoff-attempt-continuation:2026-04-10T00:00:00.000Z:reviewer-turn', + ); + + markPairedTurnRunning({ + turnIdentity: { + turnId: handoff.turn_id!, + taskId: 'task-handoff-attempt-continuation', + taskUpdatedAt: '2026-04-10T00:00:00.000Z', + intentKind: 'reviewer-turn', + role: 'reviewer', + }, + executorServiceId: CODEX_REVIEW_SERVICE_ID, + executorAgentType: 'codex', + runId: 'run-handoff-continuation-1', + }); + + expect(getPairedTurnById(handoff.turn_id!)).toMatchObject({ + state: 'running', + attempt_no: 1, + executor_service_id: CODEX_REVIEW_SERVICE_ID, + executor_agent_type: 'codex', + }); + expect(getPairedTurnAttempts(handoff.turn_id!)).toMatchObject([ + { + attempt_no: 1, + parent_handoff_id: null, + continuation_handoff_id: handoff.id, + task_id: 'task-handoff-attempt-continuation', + task_updated_at: '2026-04-10T00:00:00.000Z', + role: 'reviewer', + intent_kind: 'reviewer-turn', + state: 'running', + executor_service_id: CODEX_REVIEW_SERVICE_ID, + executor_agent_type: 'codex', + active_run_id: 'run-handoff-continuation-1', + last_error: null, + }, + ]); + }); + + it('drops legacy paired_turn active_run_id scratch state on re-init and keeps same-run continuation on attempt rows', () => { + const tempDir = fs.mkdtempSync('/tmp/ejclaw-current-run-write-drift-'); + const dbPath = path.join(tempDir, 'messages.db'); + + const turnIdentity = buildPairedTurnIdentity({ + taskId: 'task-current-run-drift', + taskUpdatedAt: '2026-04-10T00:00:00.000Z', + intentKind: 'reviewer-turn', + role: 'reviewer', + }); + + try { + _initTestDatabaseFromFile(dbPath); + + markPairedTurnRunning({ + turnIdentity, + executorServiceId: CODEX_REVIEW_SERVICE_ID, + executorAgentType: 'codex', + runId: 'run-current-run-drift-1', + }); + + const rawDb = new Database(dbPath); + rawDb.exec(` + DROP TRIGGER IF EXISTS paired_turn_attempts_validate_insert; + DROP TRIGGER IF EXISTS paired_turn_attempts_validate_update; + `); + rawDb.exec(`ALTER TABLE paired_turns ADD COLUMN active_run_id TEXT`); + rawDb + .prepare( + ` + UPDATE paired_turns + SET active_run_id = ? + WHERE turn_id = ? + `, + ) + .run('stale-run-id', turnIdentity.turnId); + rawDb.close(); + + _initTestDatabaseFromFile(dbPath); + + const rebuiltDb = new Database(dbPath); + const pairedTurnColumns = rebuiltDb + .prepare(`PRAGMA table_info(paired_turns)`) + .all() as Array<{ name: string }>; + rebuiltDb.close(); + + expect( + pairedTurnColumns.some((column) => column.name === 'active_run_id'), + ).toBe(false); + + markPairedTurnRunning({ + turnIdentity, + executorServiceId: CODEX_REVIEW_SERVICE_ID, + executorAgentType: 'codex', + runId: 'run-current-run-drift-1', + }); + + expect(getPairedTurnById(turnIdentity.turnId)).toMatchObject({ + state: 'running', + attempt_no: 1, + }); + expect(getPairedTurnAttempts(turnIdentity.turnId)).toMatchObject([ + { + attempt_no: 1, + state: 'running', + executor_service_id: CODEX_REVIEW_SERVICE_ID, + executor_agent_type: 'codex', + active_run_id: 'run-current-run-drift-1', + }, + ]); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + _initTestDatabase(); + } + }); + + it('backfills running attempt active_run_id from lease provenance before legacy paired_turn scratch on re-init', () => { + const tempDir = fs.mkdtempSync('/tmp/ejclaw-current-run-lease-backfill-'); + const dbPath = path.join(tempDir, 'messages.db'); + + const turnIdentity = buildPairedTurnIdentity({ + taskId: 'task-current-run-lease-backfill', + taskUpdatedAt: '2026-04-10T00:00:00.000Z', + intentKind: 'reviewer-turn', + role: 'reviewer', + }); + + try { + _initTestDatabaseFromFile(dbPath); + + markPairedTurnRunning({ + turnIdentity, + executorServiceId: CODEX_REVIEW_SERVICE_ID, + executorAgentType: 'codex', + runId: 'run-correct', + }); + + const rawDb = new Database(dbPath); + rawDb.exec(` + DROP TRIGGER IF EXISTS paired_turn_attempts_validate_insert; + DROP TRIGGER IF EXISTS paired_turn_attempts_validate_update; + `); + rawDb.exec(`ALTER TABLE paired_turns ADD COLUMN active_run_id TEXT`); + rawDb + .prepare( + ` + UPDATE paired_turn_attempts + SET active_run_id = NULL + WHERE turn_id = ? + AND attempt_no = 1 + `, + ) + .run(turnIdentity.turnId); + rawDb + .prepare( + ` + UPDATE paired_turns + SET active_run_id = ? + WHERE turn_id = ? + `, + ) + .run('run-stale', turnIdentity.turnId); + rawDb + .prepare( + ` + INSERT OR REPLACE INTO paired_task_execution_leases ( + task_id, + chat_jid, + role, + turn_id, + turn_attempt_id, + turn_attempt_no, + intent_kind, + claimed_run_id, + claimed_service_id, + task_status, + task_updated_at, + claimed_at, + updated_at, + expires_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + ) + .run( + turnIdentity.taskId, + 'dc:current-run-lease-backfill', + 'reviewer', + turnIdentity.turnId, + buildPairedTurnAttemptId(turnIdentity.turnId, 1), + 1, + turnIdentity.intentKind, + 'run-correct', + CODEX_REVIEW_SERVICE_ID, + 'review_ready', + turnIdentity.taskUpdatedAt, + '2026-04-10T00:00:05.000Z', + '2026-04-10T00:00:10.000Z', + '2026-04-10T01:00:00.000Z', + ); + rawDb.close(); + + _initTestDatabaseFromFile(dbPath); + + expect(getPairedTurnAttempts(turnIdentity.turnId)).toMatchObject([ + { + attempt_no: 1, + state: 'running', + executor_service_id: CODEX_REVIEW_SERVICE_ID, + executor_agent_type: 'codex', + active_run_id: 'run-correct', + }, + ]); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + _initTestDatabase(); + } + }); + + it('keeps attempt 1 when same-run continuation follows lease-backed active_run_id after re-init', () => { + const tempDir = fs.mkdtempSync( + '/tmp/ejclaw-current-run-lease-continuation-', + ); + const dbPath = path.join(tempDir, 'messages.db'); + + const turnIdentity = buildPairedTurnIdentity({ + taskId: 'task-current-run-lease-continuation', + taskUpdatedAt: '2026-04-10T00:00:00.000Z', + intentKind: 'reviewer-turn', + role: 'reviewer', + }); + + try { + _initTestDatabaseFromFile(dbPath); + + markPairedTurnRunning({ + turnIdentity, + executorServiceId: CODEX_REVIEW_SERVICE_ID, + executorAgentType: 'codex', + runId: 'run-correct', + }); + + const rawDb = new Database(dbPath); + rawDb.exec(` + DROP TRIGGER IF EXISTS paired_turn_attempts_validate_insert; + DROP TRIGGER IF EXISTS paired_turn_attempts_validate_update; + `); + rawDb.exec(`ALTER TABLE paired_turns ADD COLUMN active_run_id TEXT`); + rawDb + .prepare( + ` + UPDATE paired_turn_attempts + SET active_run_id = NULL + WHERE turn_id = ? + AND attempt_no = 1 + `, + ) + .run(turnIdentity.turnId); + rawDb + .prepare( + ` + UPDATE paired_turns + SET active_run_id = ? + WHERE turn_id = ? + `, + ) + .run('run-stale', turnIdentity.turnId); + rawDb + .prepare( + ` + INSERT OR REPLACE INTO paired_task_execution_leases ( + task_id, + chat_jid, + role, + turn_id, + turn_attempt_id, + turn_attempt_no, + intent_kind, + claimed_run_id, + claimed_service_id, + task_status, + task_updated_at, + claimed_at, + updated_at, + expires_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + ) + .run( + turnIdentity.taskId, + 'dc:current-run-lease-continuation', + 'reviewer', + turnIdentity.turnId, + buildPairedTurnAttemptId(turnIdentity.turnId, 1), + 1, + turnIdentity.intentKind, + 'run-correct', + CODEX_REVIEW_SERVICE_ID, + 'review_ready', + turnIdentity.taskUpdatedAt, + '2026-04-10T00:00:05.000Z', + '2026-04-10T00:00:10.000Z', + '2026-04-10T01:00:00.000Z', + ); + rawDb.close(); + + _initTestDatabaseFromFile(dbPath); + + markPairedTurnRunning({ + turnIdentity, + executorServiceId: CODEX_REVIEW_SERVICE_ID, + executorAgentType: 'codex', + runId: 'run-correct', + }); + + expect(getPairedTurnById(turnIdentity.turnId)).toMatchObject({ + state: 'running', + attempt_no: 1, + }); + expect(getPairedTurnAttempts(turnIdentity.turnId)).toMatchObject([ + { + attempt_no: 1, + state: 'running', + executor_service_id: CODEX_REVIEW_SERVICE_ID, + executor_agent_type: 'codex', + active_run_id: 'run-correct', + }, + ]); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + _initTestDatabase(); + } + }); + + it('keeps attempt 1 when paired_turn state drifts away from the current attempt row', () => { + const tempDir = fs.mkdtempSync('/tmp/ejclaw-current-state-write-drift-'); + const dbPath = path.join(tempDir, 'messages.db'); + + const turnIdentity = buildPairedTurnIdentity({ + taskId: 'task-current-state-drift', + taskUpdatedAt: '2026-04-10T00:00:00.000Z', + intentKind: 'reviewer-turn', + role: 'reviewer', + }); + + try { + _initTestDatabaseFromFile(dbPath); + + markPairedTurnRunning({ + turnIdentity, + executorServiceId: CODEX_REVIEW_SERVICE_ID, + executorAgentType: 'codex', + runId: 'run-current-state-drift-1', + }); + + const rawDb = new Database(dbPath); + rawDb.exec( + `ALTER TABLE paired_turns ADD COLUMN state TEXT DEFAULT 'queued'`, + ); + rawDb + .prepare( + ` + UPDATE paired_turns + SET state = 'queued' + WHERE turn_id = ? + `, + ) + .run(turnIdentity.turnId); + rawDb.close(); + + markPairedTurnRunning({ + turnIdentity, + executorServiceId: CODEX_REVIEW_SERVICE_ID, + executorAgentType: 'codex', + runId: 'run-current-state-drift-1', + }); + + expect(getPairedTurnById(turnIdentity.turnId)).toMatchObject({ + state: 'running', + attempt_no: 1, + }); + expect(getPairedTurnAttempts(turnIdentity.turnId)).toMatchObject([ + { + attempt_no: 1, + state: 'running', + executor_service_id: CODEX_REVIEW_SERVICE_ID, + executor_agent_type: 'codex', + active_run_id: 'run-current-state-drift-1', + }, + ]); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + _initTestDatabase(); + } + }); + + it('hydrates paired turn reads from the latest attempt row when paired_turn cache is stale', () => { + const tempDir = fs.mkdtempSync('/tmp/ejclaw-current-state-read-hydration-'); + const dbPath = path.join(tempDir, 'messages.db'); + + try { + _initTestDatabaseFromFile(dbPath); + + const task: PairedTask = { + id: 'task-current-state-read-hydration', + chat_jid: 'group@test', + group_folder: 'test-group', + 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: null, + title: null, + source_ref: 'HEAD', + plan_notes: null, + review_requested_at: '2026-04-10T00:00:00.000Z', + round_trip_count: 1, + status: 'review_ready' as const, + arbiter_verdict: null, + arbiter_requested_at: null, + completion_reason: null, + created_at: '2026-04-10T00:00:00.000Z', + updated_at: '2026-04-10T00:00:00.000Z', + }; + createPairedTask(task); + + expect( + reservePairedTurnReservation({ + chatJid: task.chat_jid, + taskId: task.id, + taskStatus: task.status, + roundTripCount: task.round_trip_count, + taskUpdatedAt: task.updated_at, + intentKind: 'reviewer-turn', + runId: 'run-current-state-read-hydration-queued-1', + }), + ).toBe(true); + expect( + claimPairedTurnReservation({ + chatJid: task.chat_jid, + taskId: task.id, + taskStatus: task.status, + roundTripCount: task.round_trip_count, + taskUpdatedAt: task.updated_at, + intentKind: 'reviewer-turn', + runId: 'run-current-state-read-hydration-running-1', + }), + ).toBe(true); + + const turnIdentity = buildPairedTurnIdentity({ + taskId: task.id, + taskUpdatedAt: task.updated_at, + intentKind: 'reviewer-turn', + role: 'reviewer', + }); + failPairedTurn({ + turnIdentity, + error: 'failed reviewer read hydration attempt', + }); + + const rawDb = new Database(dbPath); + rawDb.exec( + `ALTER TABLE paired_turns ADD COLUMN state TEXT DEFAULT 'queued'`, + ); + rawDb.exec( + `ALTER TABLE paired_turns ADD COLUMN attempt_no INTEGER NOT NULL DEFAULT 0`, + ); + rawDb.exec( + `ALTER TABLE paired_turns ADD COLUMN executor_service_id TEXT`, + ); + rawDb.exec( + `ALTER TABLE paired_turns ADD COLUMN executor_agent_type TEXT`, + ); + rawDb.exec(`ALTER TABLE paired_turns ADD COLUMN last_error TEXT`); + rawDb + .prepare( + ` + UPDATE paired_turns + SET task_id = ?, + state = 'queued', + attempt_no = 99, + executor_service_id = ?, + executor_agent_type = ?, + last_error = NULL + WHERE turn_id = ? + `, + ) + .run( + 'task-stale-current-state-cache', + 'stale-service', + 'claude-code', + turnIdentity.turnId, + ); + rawDb.close(); + + expect(getPairedTurnById(turnIdentity.turnId)).toMatchObject({ + turn_id: turnIdentity.turnId, + task_id: task.id, + task_updated_at: task.updated_at, + role: 'reviewer', + intent_kind: 'reviewer-turn', + state: 'failed', + attempt_no: 1, + executor_service_id: normalizeServiceId(SERVICE_ID), + last_error: 'failed reviewer read hydration attempt', + }); + expect(getPairedTurnsForTask(task.id)).toMatchObject([ + { + turn_id: turnIdentity.turnId, + task_id: task.id, + task_updated_at: task.updated_at, + role: 'reviewer', + intent_kind: 'reviewer-turn', + state: 'failed', + attempt_no: 1, + executor_service_id: normalizeServiceId(SERVICE_ID), + last_error: 'failed reviewer read hydration attempt', + }, + ]); + expect(getPairedTurnsForTask('task-stale-current-state-cache')).toEqual( + [], + ); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + _initTestDatabase(); + } + }); + + it('keeps latest attempt hydration when a paired_turn aggregate current attempt lags the latest attempt row', () => { + const tempDir = fs.mkdtempSync('/tmp/ejclaw-current-attempt-drift-'); + const dbPath = path.join(tempDir, 'messages.db'); + + try { + _initTestDatabaseFromFile(dbPath); + + const task: PairedTask = { + id: 'task-current-attempt-drift', + chat_jid: 'group@test', + group_folder: 'test-group', + 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: null, + title: null, + source_ref: 'HEAD', + plan_notes: null, + review_requested_at: '2026-04-10T00:00:00.000Z', + round_trip_count: 1, + status: 'review_ready' as const, + arbiter_verdict: null, + arbiter_requested_at: null, + completion_reason: null, + created_at: '2026-04-10T00:00:00.000Z', + updated_at: '2026-04-10T00:00:00.000Z', + }; + createPairedTask(task); + + expect( + reservePairedTurnReservation({ + chatJid: task.chat_jid, + taskId: task.id, + taskStatus: task.status, + roundTripCount: task.round_trip_count, + taskUpdatedAt: task.updated_at, + intentKind: 'reviewer-turn', + runId: 'run-current-attempt-drift-queued-1', + }), + ).toBe(true); + expect( + claimPairedTurnReservation({ + chatJid: task.chat_jid, + taskId: task.id, + taskStatus: task.status, + roundTripCount: task.round_trip_count, + taskUpdatedAt: task.updated_at, + intentKind: 'reviewer-turn', + runId: 'run-current-attempt-drift-running-1', + }), + ).toBe(true); + + const handoff = createServiceHandoff({ + chat_jid: task.chat_jid, + group_folder: task.group_folder, + paired_task_id: task.id, + paired_task_updated_at: task.updated_at, + turn_intent_kind: 'reviewer-turn', + turn_role: 'reviewer', + source_service_id: CLAUDE_SERVICE_ID, + target_service_id: CODEX_REVIEW_SERVICE_ID, + source_role: 'owner', + target_role: 'reviewer', + source_agent_type: 'claude-code', + target_agent_type: 'codex', + prompt: 'drift latest attempt row away from aggregate', + intended_role: 'reviewer', + }); + + failServiceHandoff(handoff.id, 'delegated reviewer handoff failed'); + releasePairedTaskExecutionLease({ + taskId: task.id, + runId: 'run-current-attempt-drift-running-1', + }); + + expect( + reservePairedTurnReservation({ + chatJid: task.chat_jid, + taskId: task.id, + taskStatus: task.status, + roundTripCount: task.round_trip_count, + taskUpdatedAt: task.updated_at, + intentKind: 'reviewer-turn', + runId: 'run-current-attempt-drift-queued-2', + }), + ).toBe(true); + expect( + claimPairedTurnReservation({ + chatJid: task.chat_jid, + taskId: task.id, + taskStatus: task.status, + roundTripCount: task.round_trip_count, + taskUpdatedAt: task.updated_at, + intentKind: 'reviewer-turn', + runId: 'run-current-attempt-drift-running-2', + }), + ).toBe(true); + + const turnId = `${task.id}:${task.updated_at}:reviewer-turn`; + const rawDb = new Database(dbPath); + rawDb.exec( + `ALTER TABLE paired_turns ADD COLUMN attempt_no INTEGER NOT NULL DEFAULT 0`, + ); + rawDb + .prepare( + ` + UPDATE paired_turns + SET attempt_no = 1 + WHERE turn_id = ? + `, + ) + .run(turnId); + rawDb.close(); + + _initTestDatabaseFromFile(dbPath); + + expect(getPairedTurnById(turnId)).toMatchObject({ + turn_id: turnId, + task_id: task.id, + task_updated_at: task.updated_at, + role: 'reviewer', + intent_kind: 'reviewer-turn', + state: 'running', + attempt_no: 2, + }); + expect(getPairedTurnAttempts(turnId)).toMatchObject([ + { + attempt_no: 1, + state: 'failed', + continuation_handoff_id: handoff.id, + }, + { + attempt_no: 2, + state: 'running', + active_run_id: 'run-current-attempt-drift-running-2', + }, + ]); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + _initTestDatabase(); + } + }); + + it('keeps latest attempt hydration when a paired_turn aggregate state drifts from a running attempt row', () => { + const tempDir = fs.mkdtempSync('/tmp/ejclaw-current-state-drift-'); + const dbPath = path.join(tempDir, 'messages.db'); + + const turnIdentity = buildPairedTurnIdentity({ + taskId: 'task-current-state-drift', + taskUpdatedAt: '2026-04-10T00:00:00.000Z', + intentKind: 'reviewer-turn', + role: 'reviewer', + }); + + try { + _initTestDatabaseFromFile(dbPath); + + markPairedTurnRunning({ + turnIdentity, + executorServiceId: CODEX_REVIEW_SERVICE_ID, + executorAgentType: 'codex', + runId: 'run-current-state-drift-1', + }); + + const rawDb = new Database(dbPath); + rawDb.exec( + `ALTER TABLE paired_turns ADD COLUMN state TEXT DEFAULT 'queued'`, + ); + rawDb + .prepare( + ` + UPDATE paired_turns + SET state = 'queued' + WHERE turn_id = ? + `, + ) + .run(turnIdentity.turnId); + rawDb.close(); + + _initTestDatabaseFromFile(dbPath); + + expect(getPairedTurnById(turnIdentity.turnId)).toMatchObject({ + turn_id: turnIdentity.turnId, + task_id: turnIdentity.taskId, + task_updated_at: turnIdentity.taskUpdatedAt, + role: 'reviewer', + intent_kind: 'reviewer-turn', + state: 'running', + attempt_no: 1, + executor_service_id: CODEX_REVIEW_SERVICE_ID, + executor_agent_type: 'codex', + }); + expect(getPairedTurnAttempts(turnIdentity.turnId)).toMatchObject([ + { + attempt_no: 1, + state: 'running', + active_run_id: 'run-current-state-drift-1', + }, + ]); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + _initTestDatabase(); + } + }); + + it('fails init when a legacy paired_turn aggregate implies a non-contiguous attempt lineage', () => { + const tempDir = fs.mkdtempSync(path.join('/tmp', 'ejclaw-paired-attempt-')); + const dbPath = path.join(tempDir, 'paired-attempts.db'); + + try { + const legacyDb = new Database(dbPath); + legacyDb.exec(` + CREATE TABLE paired_turns ( + turn_id TEXT PRIMARY KEY, + task_id TEXT NOT NULL, + task_updated_at TEXT NOT NULL, + role TEXT NOT NULL, + intent_kind TEXT NOT NULL, + state TEXT NOT NULL, + executor_service_id TEXT, + executor_agent_type TEXT, + attempt_no INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + completed_at TEXT, + last_error TEXT + ); + `); + legacyDb + .prepare( + ` + INSERT INTO paired_turns ( + turn_id, + task_id, + task_updated_at, + role, + intent_kind, + state, + executor_service_id, + executor_agent_type, + attempt_no, + created_at, + updated_at, + completed_at, + last_error + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + ) + .run( + 'legacy-task:2026-04-10T00:00:00.000Z:reviewer-turn', + 'legacy-task', + '2026-04-10T00:00:00.000Z', + 'reviewer', + 'reviewer-turn', + 'failed', + CODEX_REVIEW_SERVICE_ID, + 'codex', + 2, + '2026-04-10T00:00:00.000Z', + '2026-04-10T01:00:00.000Z', + '2026-04-10T01:00:00.000Z', + 'legacy attempt failure', + ); + legacyDb.close(); + + expect(() => _initTestDatabaseFromFile(dbPath)).toThrow( + /must preserve contiguous parent lineage/, + ); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + _initTestDatabase(); + } + }); + + it('records parent_attempt_id when a retry creates a new attempt', () => { + const turnIdentity = buildPairedTurnIdentity({ + taskId: 'parent-attempt-task', + taskUpdatedAt: '2026-04-10T00:00:00.000Z', + intentKind: 'reviewer-turn', + role: 'reviewer', + }); + + markPairedTurnRunning({ + turnIdentity, + executorServiceId: CODEX_REVIEW_SERVICE_ID, + runId: 'run-1', + }); + failPairedTurn({ + turnIdentity, + error: 'attempt 1 failed', + }); + markPairedTurnRunning({ + turnIdentity, + executorServiceId: CODEX_REVIEW_SERVICE_ID, + runId: 'run-2', + }); + + expect(getPairedTurnAttempts(turnIdentity.turnId)).toEqual([ + expect.objectContaining({ + attempt_id: buildPairedTurnAttemptId(turnIdentity.turnId, 1), + parent_attempt_id: null, + attempt_no: 1, + state: 'failed', + }), + expect.objectContaining({ + attempt_id: buildPairedTurnAttemptId(turnIdentity.turnId, 2), + parent_attempt_id: buildPairedTurnAttemptParentId( + turnIdentity.turnId, + 2, + ), + attempt_no: 2, + state: 'running', + }), + ]); + }); + + it('backfills parent_attempt_id for legacy multi-attempt rows during init', () => { + const tempDir = fs.mkdtempSync( + path.join('/tmp', 'ejclaw-parent-attempt-backfill-'), + ); + const dbPath = path.join(tempDir, 'parent-attempt-backfill.db'); + const turnId = + 'legacy-parent-attempt:2026-04-10T00:00:00.000Z:reviewer-turn'; + + try { + const legacyDb = new Database(dbPath); + legacyDb.exec(` + CREATE TABLE paired_turns ( + turn_id TEXT PRIMARY KEY, + task_id TEXT NOT NULL, + task_updated_at TEXT NOT NULL, + role TEXT NOT NULL, + intent_kind TEXT NOT NULL, + state TEXT NOT NULL, + executor_service_id TEXT, + executor_agent_type TEXT, + active_run_id TEXT, + attempt_no INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + completed_at TEXT, + last_error TEXT + ); + CREATE TABLE paired_turn_attempts ( + turn_id TEXT NOT NULL, + attempt_no INTEGER NOT NULL, + task_id TEXT NOT NULL, + task_updated_at TEXT NOT NULL, + role TEXT NOT NULL, + intent_kind TEXT NOT NULL, + state TEXT NOT NULL, + executor_service_id TEXT, + executor_agent_type TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + completed_at TEXT, + last_error TEXT, + PRIMARY KEY (turn_id, attempt_no) + ); + `); + legacyDb + .prepare( + ` + INSERT INTO paired_turns ( + turn_id, + task_id, + task_updated_at, + role, + intent_kind, + state, + executor_service_id, + executor_agent_type, + attempt_no, + created_at, + updated_at, + completed_at, + last_error + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + ) + .run( + turnId, + 'legacy-parent-attempt', + '2026-04-10T00:00:00.000Z', + 'reviewer', + 'reviewer-turn', + 'failed', + CODEX_REVIEW_SERVICE_ID, + 'codex', + 2, + '2026-04-10T00:01:00.000Z', + '2026-04-10T00:02:40.000Z', + '2026-04-10T00:02:40.000Z', + 'attempt 2 failed', + ); + const insertAttempt = legacyDb.prepare( + ` + INSERT INTO paired_turn_attempts ( + turn_id, + attempt_no, + task_id, + task_updated_at, + role, + intent_kind, + state, + executor_service_id, + executor_agent_type, + created_at, + updated_at, + completed_at, + last_error + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + ); + insertAttempt.run( + turnId, + 1, + 'legacy-parent-attempt', + '2026-04-10T00:00:00.000Z', + 'reviewer', + 'reviewer-turn', + 'delegated', + CODEX_REVIEW_SERVICE_ID, + 'codex', + '2026-04-10T00:01:00.000Z', + '2026-04-10T00:01:50.000Z', + '2026-04-10T00:01:50.000Z', + 'attempt 1 delegated', + ); + insertAttempt.run( + turnId, + 2, + 'legacy-parent-attempt', + '2026-04-10T00:00:00.000Z', + 'reviewer', + 'reviewer-turn', + 'failed', + CODEX_REVIEW_SERVICE_ID, + 'codex', + '2026-04-10T00:02:00.000Z', + '2026-04-10T00:02:40.000Z', + '2026-04-10T00:02:40.000Z', + 'attempt 2 failed', + ); + legacyDb.close(); + + _initTestDatabaseFromFile(dbPath); + + const rawDb = new Database(dbPath, { readonly: true }); + expect( + rawDb + .prepare( + ` + SELECT attempt_no, attempt_id, parent_attempt_id + FROM paired_turn_attempts + WHERE turn_id = ? + ORDER BY attempt_no ASC + `, + ) + .all(turnId), + ).toEqual([ + { + attempt_no: 1, + attempt_id: buildPairedTurnAttemptId(turnId, 1), + parent_attempt_id: null, + }, + { + attempt_no: 2, + attempt_id: buildPairedTurnAttemptId(turnId, 2), + parent_attempt_id: buildPairedTurnAttemptParentId(turnId, 2), + }, + ]); + rawDb.close(); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + _initTestDatabase(); + } + }); + + it('fails init when legacy attempt lineage skips the previous attempt', () => { + const tempDir = fs.mkdtempSync( + path.join('/tmp', 'ejclaw-parent-attempt-gap-'), + ); + const dbPath = path.join(tempDir, 'parent-attempt-gap.db'); + const turnId = 'legacy-parent-gap:2026-04-10T00:00:00.000Z:reviewer-turn'; + + try { + const legacyDb = new Database(dbPath); + legacyDb.exec(` + CREATE TABLE paired_turns ( + turn_id TEXT PRIMARY KEY, + task_id TEXT NOT NULL, + task_updated_at TEXT NOT NULL, + role TEXT NOT NULL, + intent_kind TEXT NOT NULL, + state TEXT NOT NULL, + executor_service_id TEXT, + executor_agent_type TEXT, + active_run_id TEXT, + attempt_no INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + completed_at TEXT, + last_error TEXT + ); + CREATE TABLE paired_turn_attempts ( + turn_id TEXT NOT NULL, + attempt_no INTEGER NOT NULL, + task_id TEXT NOT NULL, + task_updated_at TEXT NOT NULL, + role TEXT NOT NULL, + intent_kind TEXT NOT NULL, + state TEXT NOT NULL, + executor_service_id TEXT, + executor_agent_type TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + completed_at TEXT, + last_error TEXT, + PRIMARY KEY (turn_id, attempt_no) + ); + `); + legacyDb + .prepare( + ` + INSERT INTO paired_turns ( + turn_id, + task_id, + task_updated_at, + role, + intent_kind, + state, + executor_service_id, + executor_agent_type, + attempt_no, + created_at, + updated_at, + completed_at, + last_error + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + ) + .run( + turnId, + 'legacy-parent-gap', + '2026-04-10T00:00:00.000Z', + 'reviewer', + 'reviewer-turn', + 'failed', + CODEX_REVIEW_SERVICE_ID, + 'codex', + 3, + '2026-04-10T00:00:00.000Z', + '2026-04-10T00:03:00.000Z', + '2026-04-10T00:03:00.000Z', + 'attempt 3 failed', + ); + const insertAttempt = legacyDb.prepare( + ` + INSERT INTO paired_turn_attempts ( + turn_id, + attempt_no, + task_id, + task_updated_at, + role, + intent_kind, + state, + executor_service_id, + executor_agent_type, + created_at, + updated_at, + completed_at, + last_error + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + ); + insertAttempt.run( + turnId, + 1, + 'legacy-parent-gap', + '2026-04-10T00:00:00.000Z', + 'reviewer', + 'reviewer-turn', + 'delegated', + CODEX_REVIEW_SERVICE_ID, + 'codex', + '2026-04-10T00:01:00.000Z', + '2026-04-10T00:01:30.000Z', + '2026-04-10T00:01:30.000Z', + 'attempt 1 delegated', + ); + insertAttempt.run( + turnId, + 3, + 'legacy-parent-gap', + '2026-04-10T00:00:00.000Z', + 'reviewer', + 'reviewer-turn', + 'failed', + CODEX_REVIEW_SERVICE_ID, + 'codex', + '2026-04-10T00:03:00.000Z', + '2026-04-10T00:03:20.000Z', + '2026-04-10T00:03:20.000Z', + 'attempt 3 failed', + ); + legacyDb.close(); + + expect(() => _initTestDatabaseFromFile(dbPath)).toThrow( + /invalid parent_attempt_id provenance/, + ); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + _initTestDatabase(); + } + }); + + it('backfills turn attempt provenance onto legacy reservations, leases, and handoffs during init', () => { + const tempDir = fs.mkdtempSync( + path.join('/tmp', 'ejclaw-turn-attempt-provenance-'), + ); + const dbPath = path.join(tempDir, 'turn-attempt-provenance.db'); + + try { + const legacyDb = new Database(dbPath); + legacyDb.exec(` + CREATE TABLE paired_turns ( + turn_id TEXT PRIMARY KEY, + task_id TEXT NOT NULL, + task_updated_at TEXT NOT NULL, + role TEXT NOT NULL, + intent_kind TEXT NOT NULL, + state TEXT NOT NULL, + executor_service_id TEXT, + executor_agent_type TEXT, + attempt_no INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + completed_at TEXT, + last_error TEXT + ); + CREATE TABLE paired_turn_attempts ( + turn_id TEXT NOT NULL, + attempt_no INTEGER NOT NULL, + task_id TEXT NOT NULL, + task_updated_at TEXT NOT NULL, + role TEXT NOT NULL, + intent_kind TEXT NOT NULL, + state TEXT NOT NULL, + executor_service_id TEXT, + executor_agent_type TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + completed_at TEXT, + last_error TEXT, + PRIMARY KEY (turn_id, attempt_no) + ); + CREATE TABLE paired_turn_reservations ( + chat_jid TEXT NOT NULL, + task_id TEXT NOT NULL, + task_status TEXT NOT NULL, + round_trip_count INTEGER NOT NULL DEFAULT 0, + task_updated_at TEXT NOT NULL, + turn_id TEXT NOT NULL, + turn_role TEXT NOT NULL, + intent_kind TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + scheduled_run_id TEXT, + consumed_run_id TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + consumed_at TEXT, + PRIMARY KEY (chat_jid, task_id, task_updated_at, intent_kind) + ); + CREATE TABLE paired_task_execution_leases ( + task_id TEXT PRIMARY KEY, + chat_jid TEXT NOT NULL, + role TEXT NOT NULL, + turn_id TEXT NOT NULL, + intent_kind TEXT NOT NULL, + claimed_run_id TEXT NOT NULL, + claimed_service_id TEXT NOT NULL, + task_status TEXT NOT NULL, + task_updated_at TEXT NOT NULL, + claimed_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + expires_at TEXT NOT NULL + ); + 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, + paired_task_id TEXT, + paired_task_updated_at TEXT, + turn_id TEXT, + turn_intent_kind TEXT, + turn_role TEXT, + source_role TEXT, + source_agent_type TEXT, + target_role TEXT, + target_agent_type TEXT NOT NULL, + prompt TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + start_seq INTEGER, + end_seq INTEGER, + reason TEXT, + intended_role TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + claimed_at TEXT, + completed_at TEXT, + last_error TEXT + ); + `); + legacyDb + .prepare( + ` + INSERT INTO paired_turns ( + turn_id, + task_id, + task_updated_at, + role, + intent_kind, + state, + executor_service_id, + executor_agent_type, + attempt_no, + created_at, + updated_at, + completed_at, + last_error + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + ) + .run( + 'legacy-provenance-task:2026-04-10T00:00:00.000Z:reviewer-turn', + 'legacy-provenance-task', + '2026-04-10T00:00:00.000Z', + 'reviewer', + 'reviewer-turn', + 'running', + 'other-service', + 'codex', + 2, + '2026-04-10T00:00:00.000Z', + '2026-04-10T01:00:00.000Z', + null, + null, + ); + const insertAttempt = legacyDb.prepare( + ` + INSERT INTO paired_turn_attempts ( + turn_id, + attempt_no, + task_id, + task_updated_at, + role, + intent_kind, + state, + executor_service_id, + executor_agent_type, + created_at, + updated_at, + completed_at, + last_error + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + ); + insertAttempt.run( + 'legacy-provenance-task:2026-04-10T00:00:00.000Z:reviewer-turn', + 1, + 'legacy-provenance-task', + '2026-04-10T00:00:00.000Z', + 'reviewer', + 'reviewer-turn', + 'delegated', + 'other-service', + 'codex', + '2026-04-10T00:00:00.000Z', + '2026-04-10T00:30:00.000Z', + '2026-04-10T00:30:00.000Z', + 'legacy attempt 1 delegated', + ); + insertAttempt.run( + 'legacy-provenance-task:2026-04-10T00:00:00.000Z:reviewer-turn', + 2, + 'legacy-provenance-task', + '2026-04-10T00:00:00.000Z', + 'reviewer', + 'reviewer-turn', + 'running', + 'other-service', + 'codex', + '2026-04-10T00:40:00.000Z', + '2026-04-10T01:00:00.000Z', + null, + null, + ); + legacyDb + .prepare( + ` + INSERT INTO paired_turn_reservations ( + chat_jid, + task_id, + task_status, + round_trip_count, + task_updated_at, + turn_id, + turn_role, + intent_kind, + status, + scheduled_run_id, + consumed_run_id, + created_at, + updated_at, + consumed_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + ) + .run( + 'group@test', + 'legacy-provenance-task', + 'review_ready', + 1, + '2026-04-10T00:00:00.000Z', + 'legacy-provenance-task:2026-04-10T00:00:00.000Z:reviewer-turn', + 'reviewer', + 'reviewer-turn', + 'completed', + 'run-scheduled', + 'run-consumed', + '2026-04-10T00:00:00.000Z', + '2026-04-10T01:00:00.000Z', + '2026-04-10T01:00:00.000Z', + ); + legacyDb + .prepare( + ` + INSERT INTO paired_task_execution_leases ( + task_id, + chat_jid, + role, + turn_id, + intent_kind, + claimed_run_id, + claimed_service_id, + task_status, + task_updated_at, + claimed_at, + updated_at, + expires_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + ) + .run( + 'legacy-provenance-task', + 'group@test', + 'reviewer', + 'legacy-provenance-task:2026-04-10T00:00:00.000Z:reviewer-turn', + 'reviewer-turn', + 'run-active', + 'other-service', + 'review_ready', + '2026-04-10T00:00:00.000Z', + '2026-04-10T00:00:00.000Z', + '2026-04-10T01:00:00.000Z', + '2099-04-10T01:10:00.000Z', + ); + legacyDb + .prepare( + ` + INSERT INTO service_handoffs ( + chat_jid, + group_folder, + source_service_id, + target_service_id, + paired_task_id, + paired_task_updated_at, + turn_id, + turn_intent_kind, + turn_role, + source_role, + source_agent_type, + target_role, + target_agent_type, + prompt, + status, + intended_role, + created_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + ) + .run( + 'group@test', + 'test-group', + CLAUDE_SERVICE_ID, + CODEX_REVIEW_SERVICE_ID, + 'legacy-provenance-task', + '2026-04-10T00:00:00.000Z', + 'legacy-provenance-task:2026-04-10T00:00:00.000Z:reviewer-turn', + 'reviewer-turn', + 'reviewer', + 'owner', + 'claude-code', + 'reviewer', + 'codex', + 'legacy provenance handoff', + 'pending', + 'reviewer', + '2026-04-10T00:50:00.000Z', + ); + legacyDb.close(); + + _initTestDatabaseFromFile(dbPath); + + const rawDb = new Database(dbPath, { readonly: true }); + expect( + rawDb + .prepare( + `SELECT turn_attempt_no + FROM paired_turn_reservations + WHERE turn_id = ?`, + ) + .get('legacy-provenance-task:2026-04-10T00:00:00.000Z:reviewer-turn'), + ).toEqual({ turn_attempt_no: 2 }); + expect( + rawDb + .prepare( + `SELECT turn_attempt_no + FROM paired_task_execution_leases + WHERE turn_id = ?`, + ) + .get('legacy-provenance-task:2026-04-10T00:00:00.000Z:reviewer-turn'), + ).toEqual({ turn_attempt_no: 2 }); + expect( + rawDb + .prepare( + `SELECT turn_attempt_no + FROM service_handoffs + WHERE turn_id = ?`, + ) + .get('legacy-provenance-task:2026-04-10T00:00:00.000Z:reviewer-turn'), + ).toEqual({ turn_attempt_no: 2 }); + rawDb.close(); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + _initTestDatabase(); + } + }); + + it('preserves per-row attempt provenance when backfilling a multi-attempt legacy turn', () => { + const tempDir = fs.mkdtempSync( + path.join('/tmp', 'ejclaw-turn-attempt-provenance-multi-'), + ); + const dbPath = path.join(tempDir, 'turn-attempt-provenance-multi.db'); + const turnId = + 'legacy-multi-provenance:2026-04-10T00:00:00.000Z:reviewer-turn'; + + try { + const legacyDb = new Database(dbPath); + initializeDatabaseSchema(legacyDb); + + insertPairedTurnIdentityRow(legacyDb, { + turnId, + taskId: 'legacy-multi-provenance', + taskUpdatedAt: '2026-04-10T00:00:00.000Z', + role: 'reviewer', + intentKind: 'reviewer-turn', + createdAt: '2026-04-10T00:01:00.000Z', + updatedAt: '2026-04-10T00:02:40.000Z', + }); + + const insertAttempt = legacyDb.prepare( + ` + INSERT INTO paired_turn_attempts ( + attempt_id, + parent_attempt_id, + turn_id, + attempt_no, + task_id, + task_updated_at, + role, + intent_kind, + state, + executor_service_id, + executor_agent_type, + created_at, + updated_at, + completed_at, + last_error + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + ); + insertAttempt.run( + buildPairedTurnAttemptId(turnId, 1), + null, + turnId, + 1, + 'legacy-multi-provenance', + '2026-04-10T00:00:00.000Z', + 'reviewer', + 'reviewer-turn', + 'delegated', + CODEX_REVIEW_SERVICE_ID, + 'codex', + '2026-04-10T00:01:00.000Z', + '2026-04-10T00:01:50.000Z', + '2026-04-10T00:01:50.000Z', + 'attempt 1 delegated', + ); + insertAttempt.run( + buildPairedTurnAttemptId(turnId, 2), + buildPairedTurnAttemptParentId(turnId, 2), + turnId, + 2, + 'legacy-multi-provenance', + '2026-04-10T00:00:00.000Z', + 'reviewer', + 'reviewer-turn', + 'failed', + CODEX_REVIEW_SERVICE_ID, + 'codex', + '2026-04-10T00:02:00.000Z', + '2026-04-10T00:02:40.000Z', + '2026-04-10T00:02:40.000Z', + 'attempt 2 failed', + ); + + legacyDb + .prepare( + ` + INSERT INTO paired_turn_reservations ( + chat_jid, + task_id, + task_status, + round_trip_count, + task_updated_at, + turn_id, + turn_attempt_no, + turn_role, + intent_kind, + status, + scheduled_run_id, + consumed_run_id, + created_at, + updated_at, + consumed_at + ) + VALUES (?, ?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?) + `, + ) + .run( + 'group@test', + 'legacy-multi-provenance', + 'review_ready', + 1, + '2026-04-10T00:00:00.000Z', + turnId, + 'reviewer', + 'reviewer-turn', + 'completed', + 'run-scheduled-1', + 'run-consumed-1', + '2026-04-10T00:00:30.000Z', + '2026-04-10T00:01:10.000Z', + '2026-04-10T00:01:10.000Z', + ); + + legacyDb + .prepare( + ` + INSERT INTO paired_task_execution_leases ( + task_id, + chat_jid, + role, + turn_id, + turn_attempt_no, + intent_kind, + claimed_run_id, + claimed_service_id, + task_status, + task_updated_at, + claimed_at, + updated_at, + expires_at + ) + VALUES (?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?) + `, + ) + .run( + 'legacy-multi-provenance', + 'group@test', + 'reviewer', + turnId, + 'reviewer-turn', + 'run-active-1', + CODEX_REVIEW_SERVICE_ID, + 'review_ready', + '2026-04-10T00:00:00.000Z', + '2026-04-10T00:01:15.000Z', + '2026-04-10T00:01:20.000Z', + '2099-04-10T00:11:20.000Z', + ); + + const insertHandoff = legacyDb.prepare( + ` + INSERT INTO service_handoffs ( + chat_jid, + group_folder, + paired_task_id, + paired_task_updated_at, + turn_id, + turn_attempt_no, + turn_intent_kind, + turn_role, + source_service_id, + target_service_id, + source_role, + source_agent_type, + target_role, + target_agent_type, + prompt, + status, + intended_role, + created_at, + claimed_at, + completed_at, + last_error + ) + VALUES (?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + ); + insertHandoff.run( + 'group@test', + 'test-group', + 'legacy-multi-provenance', + '2026-04-10T00:00:00.000Z', + turnId, + 'reviewer-turn', + 'reviewer', + CLAUDE_SERVICE_ID, + CODEX_REVIEW_SERVICE_ID, + 'owner', + 'claude-code', + 'reviewer', + 'codex', + 'legacy handoff attempt 1', + 'failed', + 'reviewer', + '2026-04-10T00:01:30.000Z', + '2026-04-10T00:01:35.000Z', + '2026-04-10T00:01:50.000Z', + 'attempt 1 failed', + ); + insertHandoff.run( + 'group@test', + 'test-group', + 'legacy-multi-provenance', + '2026-04-10T00:00:00.000Z', + turnId, + 'reviewer-turn', + 'reviewer', + CLAUDE_SERVICE_ID, + CODEX_REVIEW_SERVICE_ID, + 'owner', + 'claude-code', + 'reviewer', + 'codex', + 'legacy handoff attempt 2', + 'pending', + 'reviewer', + '2026-04-10T00:02:10.000Z', + null, + null, + null, + ); + legacyDb.close(); + + _initTestDatabaseFromFile(dbPath); + + const rawDb = new Database(dbPath, { readonly: true }); + expect( + rawDb + .prepare( + ` + SELECT turn_attempt_no + FROM paired_turn_reservations + WHERE turn_id = ? + `, + ) + .get(turnId), + ).toEqual({ turn_attempt_no: 1 }); + expect( + rawDb + .prepare( + ` + SELECT turn_attempt_no + FROM paired_task_execution_leases + WHERE turn_id = ? + `, + ) + .get(turnId), + ).toEqual({ turn_attempt_no: 1 }); + expect( + rawDb + .prepare( + ` + SELECT id, turn_attempt_no + FROM service_handoffs + WHERE turn_id = ? + ORDER BY id ASC + `, + ) + .all(turnId), + ).toEqual([ + { id: 1, turn_attempt_no: 1 }, + { id: 2, turn_attempt_no: 2 }, + ]); + expect( + rawDb + .prepare( + ` + SELECT attempt_no, parent_attempt_id + FROM paired_turn_attempts + WHERE turn_id = ? + ORDER BY attempt_no ASC + `, + ) + .all(turnId), + ).toEqual([ + { attempt_no: 1, parent_attempt_id: null }, + { + attempt_no: 2, + parent_attempt_id: buildPairedTurnAttemptParentId(turnId, 2), + }, + ]); + rawDb.close(); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + _initTestDatabase(); + } + }); + + it('rejects mismatched turn-attempt provenance writes across attempt-backed tables', () => { + const database = new Database(':memory:'); + const turnId = + 'trigger-provenance-task:2026-04-10T00:00:00.000Z:reviewer-turn'; + + try { + initializeDatabaseSchema(database); + + insertPairedTurnIdentityRow(database, { + turnId, + taskId: 'trigger-provenance-task', + taskUpdatedAt: '2026-04-10T00:00:00.000Z', + role: 'reviewer', + intentKind: 'reviewer-turn', + createdAt: '2026-04-10T00:00:00.000Z', + updatedAt: '2026-04-10T00:00:00.000Z', + }); + + expect(() => + database + .prepare( + ` + INSERT INTO paired_turn_attempts ( + attempt_id, + turn_id, + attempt_no, + task_id, + task_updated_at, + role, + intent_kind, + state, + executor_service_id, + executor_agent_type, + active_run_id, + created_at, + updated_at, + completed_at, + last_error + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + ) + .run( + buildPairedTurnAttemptId(turnId, 1), + turnId, + 1, + 'trigger-provenance-task', + '2026-04-10T00:00:00.000Z', + 'owner', + 'reviewer-turn', + 'running', + CODEX_REVIEW_SERVICE_ID, + 'codex', + 'run-trigger-provenance-1', + '2026-04-10T00:00:00.000Z', + '2026-04-10T00:00:00.000Z', + null, + null, + ), + ).toThrow( + /paired_turn_attempts must reference a matching paired_turns row/, + ); + + database + .prepare( + ` + INSERT INTO paired_turn_attempts ( + attempt_id, + turn_id, + attempt_no, + task_id, + task_updated_at, + role, + intent_kind, + state, + executor_service_id, + executor_agent_type, + active_run_id, + created_at, + updated_at, + completed_at, + last_error + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + ) + .run( + buildPairedTurnAttemptId(turnId, 1), + turnId, + 1, + 'trigger-provenance-task', + '2026-04-10T00:00:00.000Z', + 'reviewer', + 'reviewer-turn', + 'running', + CODEX_REVIEW_SERVICE_ID, + 'codex', + 'run-trigger-provenance-2', + '2026-04-10T00:00:00.000Z', + '2026-04-10T00:00:00.000Z', + null, + null, + ); + + expect(() => + database + .prepare( + ` + INSERT INTO paired_turn_reservations ( + chat_jid, + task_id, + task_status, + round_trip_count, + task_updated_at, + turn_id, + turn_attempt_id, + turn_attempt_no, + turn_role, + intent_kind, + status, + scheduled_run_id, + consumed_run_id, + created_at, + updated_at, + consumed_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + ) + .run( + 'group@test', + 'trigger-provenance-task', + 'review_ready', + 1, + '2026-04-10T00:00:00.000Z', + turnId, + 'bad-attempt-id', + 1, + 'owner', + 'reviewer-turn', + 'completed', + 'run-scheduled-1', + 'run-consumed-1', + '2026-04-10T00:00:10.000Z', + '2026-04-10T00:00:20.000Z', + '2026-04-10T00:00:20.000Z', + ), + ).toThrow( + /paired_turn_reservations turn_attempt_no must reference a matching paired_turn_attempts row/, + ); + + expect(() => + database + .prepare( + ` + INSERT INTO paired_task_execution_leases ( + task_id, + chat_jid, + role, + turn_id, + turn_attempt_id, + turn_attempt_no, + intent_kind, + claimed_run_id, + claimed_service_id, + task_status, + task_updated_at, + claimed_at, + updated_at, + expires_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + ) + .run( + 'trigger-provenance-task', + 'group@test', + 'reviewer', + turnId, + 'bad-attempt-id', + 1, + 'reviewer-turn', + 'run-1', + CODEX_REVIEW_SERVICE_ID, + 'review_ready', + '2026-04-10T00:05:00.000Z', + '2026-04-10T00:00:15.000Z', + '2026-04-10T00:00:16.000Z', + '2099-04-10T00:10:16.000Z', + ), + ).toThrow( + /paired_task_execution_leases turn_attempt_no must reference a matching paired_turn_attempts row/, + ); + + expect(() => + database + .prepare( + ` + INSERT INTO service_handoffs ( + chat_jid, + group_folder, + paired_task_id, + paired_task_updated_at, + turn_id, + turn_attempt_id, + turn_attempt_no, + turn_intent_kind, + turn_role, + source_service_id, + target_service_id, + source_role, + source_agent_type, + target_role, + target_agent_type, + prompt, + status, + intended_role, + created_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + ) + .run( + 'group@test', + 'test-group', + 'trigger-provenance-task', + '2026-04-10T00:05:00.000Z', + turnId, + 'bad-attempt-id', + 1, + 'reviewer-turn', + 'reviewer', + CLAUDE_SERVICE_ID, + CODEX_REVIEW_SERVICE_ID, + 'owner', + 'claude-code', + 'reviewer', + 'codex', + 'trigger provenance handoff', + 'pending', + 'reviewer', + '2026-04-10T00:00:25.000Z', + ), + ).toThrow( + /service_handoffs turn_attempt_no must reference a matching paired_turn_attempts row/, + ); + } finally { + database.close(); + } + }); + + it('fails init when a legacy handoff keeps an invalid turn_attempt_no provenance reference', () => { + const tempDir = fs.mkdtempSync( + path.join('/tmp', 'ejclaw-invalid-handoff-turn-attempt-'), + ); + const dbPath = path.join(tempDir, 'invalid-handoff-turn-attempt.db'); + const turnId = + 'legacy-invalid-handoff:2026-04-10T00:00:00.000Z:reviewer-turn'; + + try { + const legacyDb = new Database(dbPath); + initializeDatabaseSchema(legacyDb); + legacyDb.exec(` + DROP TRIGGER IF EXISTS service_handoffs_validate_attempt_insert; + DROP TRIGGER IF EXISTS service_handoffs_validate_attempt_update; + `); + + insertPairedTurnIdentityRow(legacyDb, { + turnId, + taskId: 'legacy-invalid-handoff', + taskUpdatedAt: '2026-04-10T00:00:00.000Z', + role: 'reviewer', + intentKind: 'reviewer-turn', + createdAt: '2026-04-10T00:00:00.000Z', + updatedAt: '2026-04-10T00:01:00.000Z', + }); + legacyDb + .prepare( + ` + INSERT INTO paired_turn_attempts ( + attempt_id, + turn_id, + attempt_no, + task_id, + task_updated_at, + role, + intent_kind, + state, + executor_service_id, + executor_agent_type, + created_at, + updated_at, + completed_at, + last_error + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + ) + .run( + buildPairedTurnAttemptId(turnId, 1), + turnId, + 1, + 'legacy-invalid-handoff', + '2026-04-10T00:00:00.000Z', + 'reviewer', + 'reviewer-turn', + 'failed', + CODEX_REVIEW_SERVICE_ID, + 'codex', + '2026-04-10T00:00:00.000Z', + '2026-04-10T00:01:00.000Z', + '2026-04-10T00:01:00.000Z', + 'attempt failed', + ); + legacyDb + .prepare( + ` + INSERT INTO service_handoffs ( + chat_jid, + group_folder, + paired_task_id, + paired_task_updated_at, + turn_id, + turn_attempt_no, + turn_intent_kind, + turn_role, + source_service_id, + target_service_id, + source_role, + source_agent_type, + target_role, + target_agent_type, + prompt, + status, + intended_role, + created_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + ) + .run( + 'group@test', + 'test-group', + 'legacy-invalid-handoff', + '2026-04-10T00:05:00.000Z', + turnId, + 1, + 'reviewer-turn', + 'reviewer', + CLAUDE_SERVICE_ID, + CODEX_REVIEW_SERVICE_ID, + 'owner', + 'claude-code', + 'reviewer', + 'codex', + 'legacy invalid handoff', + 'failed', + 'reviewer', + '2026-04-10T00:00:30.000Z', + ); + legacyDb.close(); + + expect(() => _initTestDatabaseFromFile(dbPath)).toThrow( + /service_handoffs\(id=1\) has invalid paired_turn_attempt provenance/, + ); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + _initTestDatabase(); + } + }); + + it('fails init when an attempt keeps an invalid parent_handoff_id provenance reference', () => { + const tempDir = fs.mkdtempSync( + path.join('/tmp', 'ejclaw-invalid-parent-handoff-'), + ); + const dbPath = path.join(tempDir, 'invalid-parent-handoff.db'); + const turnId = + 'legacy-invalid-parent-handoff:2026-04-10T00:00:00.000Z:reviewer-turn'; + const otherTurnId = + 'legacy-other-parent-handoff:2026-04-10T00:00:00.000Z:reviewer-turn'; + + try { + const legacyDb = new Database(dbPath); + initializeDatabaseSchema(legacyDb); + legacyDb.exec(` + DROP TRIGGER IF EXISTS paired_turn_attempts_validate_insert; + DROP TRIGGER IF EXISTS paired_turn_attempts_validate_update; + `); + + const insertTurn = (args: { + turnId: string; + taskId: string; + taskUpdatedAt: string; + role: 'owner' | 'reviewer' | 'arbiter'; + intentKind: + | 'owner-turn' + | 'reviewer-turn' + | 'arbiter-turn' + | 'owner-follow-up' + | 'finalize-owner-turn'; + createdAt: string; + updatedAt: string; + }) => insertPairedTurnIdentityRow(legacyDb, args); + const insertAttempt = legacyDb.prepare( + ` + INSERT INTO paired_turn_attempts ( + attempt_id, + parent_attempt_id, + parent_handoff_id, + turn_id, + attempt_no, + task_id, + task_updated_at, + role, + intent_kind, + state, + executor_service_id, + executor_agent_type, + created_at, + updated_at, + completed_at, + last_error + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + ); + + insertTurn({ + turnId: otherTurnId, + taskId: 'legacy-other-parent-handoff', + taskUpdatedAt: '2026-04-10T00:00:00.000Z', + role: 'reviewer', + intentKind: 'reviewer-turn', + createdAt: '2026-04-10T00:00:00.000Z', + updatedAt: '2026-04-10T00:00:30.000Z', + }); + insertAttempt.run( + buildPairedTurnAttemptId(otherTurnId, 1), + null, + null, + otherTurnId, + 1, + 'legacy-other-parent-handoff', + '2026-04-10T00:00:00.000Z', + 'reviewer', + 'reviewer-turn', + 'delegated', + CODEX_REVIEW_SERVICE_ID, + 'codex', + '2026-04-10T00:00:00.000Z', + '2026-04-10T00:00:30.000Z', + null, + null, + ); + legacyDb + .prepare( + ` + INSERT INTO service_handoffs ( + chat_jid, + group_folder, + paired_task_id, + paired_task_updated_at, + turn_id, + turn_attempt_id, + turn_attempt_no, + turn_intent_kind, + turn_role, + source_service_id, + target_service_id, + source_role, + source_agent_type, + target_role, + target_agent_type, + prompt, + status, + intended_role, + created_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + ) + .run( + 'group@test', + 'test-group', + 'legacy-other-parent-handoff', + '2026-04-10T00:00:00.000Z', + otherTurnId, + buildPairedTurnAttemptId(otherTurnId, 1), + 1, + 'reviewer-turn', + 'reviewer', + CLAUDE_SERVICE_ID, + CODEX_REVIEW_SERVICE_ID, + 'owner', + 'claude-code', + 'reviewer', + 'codex', + 'wrong parent handoff', + 'failed', + 'reviewer', + '2026-04-10T00:00:20.000Z', + ); + const wrongHandoffId = ( + legacyDb.prepare('SELECT last_insert_rowid() AS id').get() as { + id: number; + } + ).id; + + insertTurn({ + turnId, + taskId: 'legacy-invalid-parent-handoff', + taskUpdatedAt: '2026-04-10T00:00:00.000Z', + role: 'reviewer', + intentKind: 'reviewer-turn', + createdAt: '2026-04-10T00:00:00.000Z', + updatedAt: '2026-04-10T00:01:00.000Z', + }); + insertAttempt.run( + buildPairedTurnAttemptId(turnId, 1), + null, + null, + turnId, + 1, + 'legacy-invalid-parent-handoff', + '2026-04-10T00:00:00.000Z', + 'reviewer', + 'reviewer-turn', + 'failed', + CODEX_REVIEW_SERVICE_ID, + 'codex', + '2026-04-10T00:00:00.000Z', + '2026-04-10T00:00:40.000Z', + '2026-04-10T00:00:40.000Z', + 'attempt 1 failed', + ); + insertAttempt.run( + buildPairedTurnAttemptId(turnId, 2), + buildPairedTurnAttemptParentId(turnId, 2), + wrongHandoffId, + turnId, + 2, + 'legacy-invalid-parent-handoff', + '2026-04-10T00:00:00.000Z', + 'reviewer', + 'reviewer-turn', + 'running', + CODEX_REVIEW_SERVICE_ID, + 'codex', + '2026-04-10T00:01:00.000Z', + '2026-04-10T00:01:00.000Z', + null, + null, + ); + legacyDb.close(); + + expect(() => _initTestDatabaseFromFile(dbPath)).toThrow( + /invalid parent_handoff_id provenance/, + ); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + _initTestDatabase(); + } + }); + + it('fails init when an attempt keeps a completed parent_handoff_id provenance reference', () => { + const tempDir = fs.mkdtempSync( + path.join('/tmp', 'ejclaw-completed-parent-handoff-'), + ); + const dbPath = path.join(tempDir, 'completed-parent-handoff.db'); + const turnId = + 'legacy-completed-parent-handoff:2026-04-10T00:00:00.000Z:reviewer-turn'; + + try { + const legacyDb = new Database(dbPath); + initializeDatabaseSchema(legacyDb); + legacyDb.exec(` + DROP TRIGGER IF EXISTS paired_turn_attempts_validate_insert; + DROP TRIGGER IF EXISTS paired_turn_attempts_validate_update; + `); + + const insertTurn = (args: { + turnId: string; + taskId: string; + taskUpdatedAt: string; + role: 'owner' | 'reviewer' | 'arbiter'; + intentKind: + | 'owner-turn' + | 'reviewer-turn' + | 'arbiter-turn' + | 'owner-follow-up' + | 'finalize-owner-turn'; + createdAt: string; + updatedAt: string; + }) => insertPairedTurnIdentityRow(legacyDb, args); + const insertAttempt = legacyDb.prepare( + ` + INSERT INTO paired_turn_attempts ( + attempt_id, + parent_attempt_id, + parent_handoff_id, + turn_id, + attempt_no, + task_id, + task_updated_at, + role, + intent_kind, + state, + executor_service_id, + executor_agent_type, + created_at, + updated_at, + completed_at, + last_error + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + ); + + insertTurn({ + turnId, + taskId: 'legacy-completed-parent-handoff', + taskUpdatedAt: '2026-04-10T00:00:00.000Z', + role: 'reviewer', + intentKind: 'reviewer-turn', + createdAt: '2026-04-10T00:00:00.000Z', + updatedAt: '2026-04-10T00:01:00.000Z', + }); + insertAttempt.run( + buildPairedTurnAttemptId(turnId, 1), + null, + null, + turnId, + 1, + 'legacy-completed-parent-handoff', + '2026-04-10T00:00:00.000Z', + 'reviewer', + 'reviewer-turn', + 'completed', + CODEX_REVIEW_SERVICE_ID, + 'codex', + '2026-04-10T00:00:00.000Z', + '2026-04-10T00:00:40.000Z', + '2026-04-10T00:00:40.000Z', + null, + ); + legacyDb + .prepare( + ` + INSERT INTO service_handoffs ( + chat_jid, + group_folder, + paired_task_id, + paired_task_updated_at, + turn_id, + turn_attempt_id, + turn_attempt_no, + turn_intent_kind, + turn_role, + source_service_id, + target_service_id, + source_role, + source_agent_type, + target_role, + target_agent_type, + prompt, + status, + intended_role, + created_at, + completed_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + ) + .run( + 'group@test', + 'test-group', + 'legacy-completed-parent-handoff', + '2026-04-10T00:00:00.000Z', + turnId, + buildPairedTurnAttemptId(turnId, 1), + 1, + 'reviewer-turn', + 'reviewer', + CLAUDE_SERVICE_ID, + CODEX_REVIEW_SERVICE_ID, + 'owner', + 'claude-code', + 'reviewer', + 'codex', + 'completed handoff cannot seed retry lineage', + 'completed', + 'reviewer', + '2026-04-10T00:00:20.000Z', + '2026-04-10T00:00:30.000Z', + ); + const completedHandoffId = ( + legacyDb.prepare('SELECT last_insert_rowid() AS id').get() as { + id: number; + } + ).id; + + insertAttempt.run( + buildPairedTurnAttemptId(turnId, 2), + buildPairedTurnAttemptParentId(turnId, 2), + completedHandoffId, + turnId, + 2, + 'legacy-completed-parent-handoff', + '2026-04-10T00:00:00.000Z', + 'reviewer', + 'reviewer-turn', + 'running', + CODEX_REVIEW_SERVICE_ID, + 'codex', + '2026-04-10T00:01:00.000Z', + '2026-04-10T00:01:00.000Z', + null, + null, + ); + legacyDb.close(); + + expect(() => _initTestDatabaseFromFile(dbPath)).toThrow( + /invalid parent_handoff_id provenance/, + ); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + _initTestDatabase(); + } + }); + + it('fails init when an attempt keeps an invalid continuation_handoff_id provenance reference', () => { + const tempDir = fs.mkdtempSync( + path.join('/tmp', 'ejclaw-invalid-continuation-handoff-'), + ); + const dbPath = path.join(tempDir, 'invalid-continuation-handoff.db'); + const turnId = + 'legacy-invalid-continuation-handoff:2026-04-10T00:00:00.000Z:reviewer-turn'; + const otherTurnId = + 'legacy-other-continuation-handoff:2026-04-10T00:00:00.000Z:reviewer-turn'; + + try { + const legacyDb = new Database(dbPath); + initializeDatabaseSchema(legacyDb); + legacyDb.exec(` + DROP TRIGGER IF EXISTS paired_turn_attempts_validate_insert; + DROP TRIGGER IF EXISTS paired_turn_attempts_validate_update; + `); + + const insertTurn = (args: { + turnId: string; + taskId: string; + taskUpdatedAt: string; + role: 'owner' | 'reviewer' | 'arbiter'; + intentKind: + | 'owner-turn' + | 'reviewer-turn' + | 'arbiter-turn' + | 'owner-follow-up' + | 'finalize-owner-turn'; + createdAt: string; + updatedAt: string; + }) => + insertPairedTurnIdentityRow(legacyDb, { + turnId: args.turnId, + taskId: args.taskId, + taskUpdatedAt: args.taskUpdatedAt, + role: args.role, + intentKind: args.intentKind, + createdAt: args.createdAt, + updatedAt: args.updatedAt, + }); + const insertAttempt = legacyDb.prepare( + ` + INSERT INTO paired_turn_attempts ( + attempt_id, + parent_attempt_id, + parent_handoff_id, + continuation_handoff_id, + turn_id, + attempt_no, + task_id, + task_updated_at, + role, + intent_kind, + state, + executor_service_id, + executor_agent_type, + created_at, + updated_at, + completed_at, + last_error + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + ); + + insertTurn({ + turnId: otherTurnId, + taskId: 'legacy-other-continuation-handoff', + taskUpdatedAt: '2026-04-10T00:00:00.000Z', + role: 'reviewer', + intentKind: 'reviewer-turn', + createdAt: '2026-04-10T00:00:00.000Z', + updatedAt: '2026-04-10T00:00:30.000Z', + }); + insertAttempt.run( + buildPairedTurnAttemptId(otherTurnId, 1), + null, + null, + null, + otherTurnId, + 1, + 'legacy-other-continuation-handoff', + '2026-04-10T00:00:00.000Z', + 'reviewer', + 'reviewer-turn', + 'delegated', + CODEX_REVIEW_SERVICE_ID, + 'codex', + '2026-04-10T00:00:00.000Z', + '2026-04-10T00:00:30.000Z', + null, + null, + ); + legacyDb + .prepare( + ` + INSERT INTO service_handoffs ( + chat_jid, + group_folder, + paired_task_id, + paired_task_updated_at, + turn_id, + turn_attempt_id, + turn_attempt_no, + turn_intent_kind, + turn_role, + source_service_id, + target_service_id, + source_role, + source_agent_type, + target_role, + target_agent_type, + prompt, + status, + intended_role, + created_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + ) + .run( + 'group@test', + 'test-group', + 'legacy-other-continuation-handoff', + '2026-04-10T00:00:00.000Z', + otherTurnId, + buildPairedTurnAttemptId(otherTurnId, 1), + 1, + 'reviewer-turn', + 'reviewer', + CLAUDE_SERVICE_ID, + CODEX_REVIEW_SERVICE_ID, + 'owner', + 'claude-code', + 'reviewer', + 'codex', + 'wrong continuation handoff', + 'claimed', + 'reviewer', + '2026-04-10T00:00:20.000Z', + ); + const wrongHandoffId = ( + legacyDb.prepare('SELECT last_insert_rowid() AS id').get() as { + id: number; + } + ).id; + + insertTurn({ + turnId, + taskId: 'legacy-invalid-continuation-handoff', + taskUpdatedAt: '2026-04-10T00:00:00.000Z', + role: 'reviewer', + intentKind: 'reviewer-turn', + createdAt: '2026-04-10T00:00:00.000Z', + updatedAt: '2026-04-10T00:01:00.000Z', + }); + insertAttempt.run( + buildPairedTurnAttemptId(turnId, 1), + null, + null, + wrongHandoffId, + turnId, + 1, + 'legacy-invalid-continuation-handoff', + '2026-04-10T00:00:00.000Z', + 'reviewer', + 'reviewer-turn', + 'running', + CODEX_REVIEW_SERVICE_ID, + 'codex', + '2026-04-10T00:01:00.000Z', + '2026-04-10T00:01:00.000Z', + null, + null, + ); + legacyDb.close(); + + expect(() => _initTestDatabaseFromFile(dbPath)).toThrow( + /invalid continuation_handoff_id provenance/, + ); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + _initTestDatabase(); + } + }); + + it('rebuilds legacy turn-attempt provenance tables with actual foreign keys on init', () => { + const tempDir = fs.mkdtempSync( + path.join('/tmp', 'ejclaw-turn-attempt-fk-rebuild-'), + ); + const dbPath = path.join(tempDir, 'turn-attempt-fk-rebuild.db'); + + try { + const legacyDb = new Database(dbPath); + legacyDb.exec(` + CREATE TABLE paired_turns ( + turn_id TEXT PRIMARY KEY, + task_id TEXT NOT NULL, + task_updated_at TEXT NOT NULL, + role TEXT NOT NULL, + intent_kind TEXT NOT NULL, + state TEXT NOT NULL DEFAULT 'queued', + executor_service_id TEXT, + executor_agent_type TEXT, + active_run_id TEXT, + attempt_no INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + completed_at TEXT, + last_error TEXT + ); + CREATE TABLE paired_turn_attempts ( + turn_id TEXT NOT NULL, + attempt_no INTEGER NOT NULL, + task_id TEXT NOT NULL, + task_updated_at TEXT NOT NULL, + role TEXT NOT NULL, + intent_kind TEXT NOT NULL, + state TEXT NOT NULL, + executor_service_id TEXT, + executor_agent_type TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + completed_at TEXT, + last_error TEXT, + PRIMARY KEY (turn_id, attempt_no) + ); + CREATE TABLE paired_turn_reservations ( + chat_jid TEXT NOT NULL, + task_id TEXT NOT NULL, + task_status TEXT NOT NULL, + round_trip_count INTEGER NOT NULL DEFAULT 0, + task_updated_at TEXT NOT NULL, + turn_id TEXT NOT NULL, + turn_attempt_no INTEGER, + turn_role TEXT NOT NULL, + intent_kind TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + scheduled_run_id TEXT, + consumed_run_id TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + consumed_at TEXT, + PRIMARY KEY (chat_jid, task_id, task_updated_at, intent_kind) + ); + CREATE TABLE paired_task_execution_leases ( + task_id TEXT PRIMARY KEY, + chat_jid TEXT NOT NULL, + role TEXT NOT NULL, + turn_id TEXT NOT NULL, + turn_attempt_no INTEGER, + intent_kind TEXT NOT NULL, + claimed_run_id TEXT NOT NULL, + claimed_service_id TEXT NOT NULL, + task_status TEXT NOT NULL, + task_updated_at TEXT NOT NULL, + claimed_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + expires_at TEXT NOT NULL + ); + 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, + paired_task_id TEXT, + paired_task_updated_at TEXT, + turn_id TEXT, + turn_attempt_no INTEGER, + turn_intent_kind TEXT, + turn_role TEXT, + source_role TEXT, + source_agent_type TEXT, + target_role TEXT, + target_agent_type TEXT NOT NULL, + prompt TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + start_seq INTEGER, + end_seq INTEGER, + reason TEXT, + intended_role TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + claimed_at TEXT, + completed_at TEXT, + last_error TEXT + ); + `); + legacyDb + .prepare( + ` + INSERT INTO paired_turns ( + turn_id, + task_id, + task_updated_at, + role, + intent_kind, + state, + executor_service_id, + executor_agent_type, + attempt_no, + created_at, + updated_at, + completed_at, + last_error + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + ) + .run( + 'fk-rebuild-turn:2026-04-10T00:00:00.000Z:reviewer-turn', + 'fk-rebuild-task', + '2026-04-10T00:00:00.000Z', + 'reviewer', + 'reviewer-turn', + 'failed', + CODEX_REVIEW_SERVICE_ID, + 'codex', + 1, + '2026-04-10T00:00:00.000Z', + '2026-04-10T00:01:00.000Z', + '2026-04-10T00:01:00.000Z', + 'attempt failed', + ); + legacyDb + .prepare( + ` + INSERT INTO paired_turn_attempts ( + turn_id, + attempt_no, + task_id, + task_updated_at, + role, + intent_kind, + state, + executor_service_id, + executor_agent_type, + created_at, + updated_at, + completed_at, + last_error + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + ) + .run( + 'fk-rebuild-turn:2026-04-10T00:00:00.000Z:reviewer-turn', + 1, + 'fk-rebuild-task', + '2026-04-10T00:00:00.000Z', + 'reviewer', + 'reviewer-turn', + 'failed', + CODEX_REVIEW_SERVICE_ID, + 'codex', + '2026-04-10T00:00:00.000Z', + '2026-04-10T00:01:00.000Z', + '2026-04-10T00:01:00.000Z', + 'attempt failed', + ); + legacyDb + .prepare( + ` + INSERT INTO paired_turn_reservations ( + chat_jid, + task_id, + task_status, + round_trip_count, + task_updated_at, + turn_id, + turn_attempt_no, + turn_role, + intent_kind, + status, + scheduled_run_id, + consumed_run_id, + created_at, + updated_at, + consumed_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + ) + .run( + 'group@test', + 'fk-rebuild-task', + 'review_ready', + 1, + '2026-04-10T00:00:00.000Z', + 'fk-rebuild-turn:2026-04-10T00:00:00.000Z:reviewer-turn', + 1, + 'reviewer', + 'reviewer-turn', + 'completed', + 'run-1', + 'run-1', + '2026-04-10T00:00:10.000Z', + '2026-04-10T00:00:20.000Z', + '2026-04-10T00:00:20.000Z', + ); + legacyDb.close(); + + _initTestDatabaseFromFile(dbPath); + + const migratedDb = new Database(dbPath, { readonly: true }); + const attemptFks = migratedDb + .prepare(`PRAGMA foreign_key_list(paired_turn_attempts)`) + .all() as Array<{ table: string; from: string; to: string }>; + const reservationFks = migratedDb + .prepare(`PRAGMA foreign_key_list(paired_turn_reservations)`) + .all() as Array<{ table: string; from: string; to: string }>; + const leaseFks = migratedDb + .prepare(`PRAGMA foreign_key_list(paired_task_execution_leases)`) + .all() as Array<{ table: string; from: string; to: string }>; + const handoffFks = migratedDb + .prepare(`PRAGMA foreign_key_list(service_handoffs)`) + .all() as Array<{ table: string; from: string; to: string }>; + + expect( + attemptFks.some( + (row) => + row.table === 'paired_turns' && + row.from === 'turn_id' && + row.to === 'turn_id', + ), + ).toBe(true); + expect( + attemptFks.some( + (row) => + row.table === 'paired_turn_attempts' && + row.from === 'parent_attempt_id' && + row.to === 'attempt_id', + ), + ).toBe(true); + expect( + attemptFks.some( + (row) => + row.table === 'service_handoffs' && + row.from === 'parent_handoff_id' && + row.to === 'id', + ), + ).toBe(true); + expect( + attemptFks.some( + (row) => + row.table === 'service_handoffs' && + row.from === 'continuation_handoff_id' && + row.to === 'id', + ), + ).toBe(true); + expect( + reservationFks.some( + (row) => + row.table === 'paired_turn_attempts' && row.from === 'turn_id', + ), + ).toBe(true); + expect( + leaseFks.some( + (row) => + row.table === 'paired_turn_attempts' && row.from === 'turn_id', + ), + ).toBe(true); + expect( + handoffFks.some( + (row) => + row.table === 'paired_turn_attempts' && row.from === 'turn_id', + ), + ).toBe(true); + migratedDb.close(); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + _initTestDatabase(); + } + }); + + it('uses real foreign keys to reject orphan attempt provenance when triggers are absent', () => { + const database = new Database(':memory:'); + + try { + initializeDatabaseSchema(database); + database.exec(` + DROP TRIGGER IF EXISTS paired_turn_attempts_validate_insert; + DROP TRIGGER IF EXISTS paired_turn_attempts_validate_update; + DROP TRIGGER IF EXISTS service_handoffs_validate_attempt_insert; + DROP TRIGGER IF EXISTS service_handoffs_validate_attempt_update; + `); + + expect(() => + database + .prepare( + ` + INSERT INTO paired_turn_attempts ( + attempt_id, + turn_id, + attempt_no, + task_id, + task_updated_at, + role, + intent_kind, + state, + executor_service_id, + executor_agent_type, + created_at, + updated_at, + completed_at, + last_error + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + ) + .run( + buildPairedTurnAttemptId( + 'orphan-turn:2026-04-10T00:00:00.000Z:reviewer-turn', + 1, + ), + 'orphan-turn:2026-04-10T00:00:00.000Z:reviewer-turn', + 1, + 'orphan-task', + '2026-04-10T00:00:00.000Z', + 'reviewer', + 'reviewer-turn', + 'failed', + CODEX_REVIEW_SERVICE_ID, + 'codex', + '2026-04-10T00:00:00.000Z', + '2026-04-10T00:01:00.000Z', + '2026-04-10T00:01:00.000Z', + 'orphan attempt', + ), + ).toThrow(/FOREIGN KEY constraint failed/); + + const turnId = 'fk-enforced-turn:2026-04-10T00:00:00.000Z:reviewer-turn'; + insertPairedTurnIdentityRow(database, { + turnId, + taskId: 'fk-enforced-task', + taskUpdatedAt: '2026-04-10T00:00:00.000Z', + role: 'reviewer', + intentKind: 'reviewer-turn', + createdAt: '2026-04-10T00:00:00.000Z', + updatedAt: '2026-04-10T00:01:00.000Z', + }); + database + .prepare( + ` + INSERT INTO paired_turn_attempts ( + attempt_id, + turn_id, + attempt_no, + task_id, + task_updated_at, + role, + intent_kind, + state, + executor_service_id, + executor_agent_type, + created_at, + updated_at, + completed_at, + last_error + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + ) + .run( + buildPairedTurnAttemptId(turnId, 1), + turnId, + 1, + 'fk-enforced-task', + '2026-04-10T00:00:00.000Z', + 'reviewer', + 'reviewer-turn', + 'failed', + CODEX_REVIEW_SERVICE_ID, + 'codex', + '2026-04-10T00:00:00.000Z', + '2026-04-10T00:01:00.000Z', + '2026-04-10T00:01:00.000Z', + 'attempt failed', + ); + expect(() => + database + .prepare( + ` + INSERT INTO paired_turn_attempts ( + attempt_id, + parent_attempt_id, + parent_handoff_id, + turn_id, + attempt_no, + task_id, + task_updated_at, + role, + intent_kind, + state, + executor_service_id, + executor_agent_type, + created_at, + updated_at, + completed_at, + last_error + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + ) + .run( + buildPairedTurnAttemptId(turnId, 2), + buildPairedTurnAttemptParentId(turnId, 2), + 999, + turnId, + 2, + 'fk-enforced-task', + '2026-04-10T00:00:00.000Z', + 'reviewer', + 'reviewer-turn', + 'failed', + CODEX_REVIEW_SERVICE_ID, + 'codex', + '2026-04-10T00:02:00.000Z', + '2026-04-10T00:02:00.000Z', + '2026-04-10T00:02:00.000Z', + 'orphan parent handoff', + ), + ).toThrow(/FOREIGN KEY constraint failed/); + + expect(() => + database + .prepare( + ` + INSERT INTO service_handoffs ( + chat_jid, + group_folder, + paired_task_id, + paired_task_updated_at, + turn_id, + turn_attempt_no, + turn_intent_kind, + turn_role, + source_service_id, + target_service_id, + source_role, + source_agent_type, + target_role, + target_agent_type, + prompt, + status, + intended_role, + created_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + ) + .run( + 'group@test', + 'test-group', + 'fk-enforced-task', + '2026-04-10T00:00:00.000Z', + turnId, + 2, + 'reviewer-turn', + 'reviewer', + CLAUDE_SERVICE_ID, + CODEX_REVIEW_SERVICE_ID, + 'owner', + 'claude-code', + 'reviewer', + 'codex', + 'orphan handoff attempt reference', + 'failed', + 'reviewer', + '2026-04-10T00:00:30.000Z', + ), + ).toThrow(/FOREIGN KEY constraint failed/); + } finally { + database.close(); + } + }); + + it('rejects direct inserts when attempt lineage skips the previous attempt', () => { + const database = new Database(':memory:'); + + try { + initializeDatabaseSchema(database); + + const turnIdentity = buildPairedTurnIdentity({ + taskId: 'trigger-parent-gap-task', + taskUpdatedAt: '2026-04-10T00:00:00.000Z', + role: 'reviewer', + intentKind: 'reviewer-turn', + }); + + insertPairedTurnIdentityRow(database, { + turnId: turnIdentity.turnId, + taskId: turnIdentity.taskId, + taskUpdatedAt: turnIdentity.taskUpdatedAt, + role: turnIdentity.role, + intentKind: turnIdentity.intentKind, + createdAt: '2026-04-10T00:00:00.000Z', + updatedAt: '2026-04-10T00:03:00.000Z', + }); + + expect(() => + database + .prepare( + ` + INSERT INTO paired_turn_attempts ( + attempt_id, + parent_attempt_id, + turn_id, + attempt_no, + task_id, + task_updated_at, + role, + intent_kind, + state, + executor_service_id, + executor_agent_type, + created_at, + updated_at, + completed_at, + last_error + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + ) + .run( + buildPairedTurnAttemptId(turnIdentity.turnId, 3), + null, + turnIdentity.turnId, + 3, + turnIdentity.taskId, + turnIdentity.taskUpdatedAt, + turnIdentity.role, + turnIdentity.intentKind, + 'failed', + CODEX_REVIEW_SERVICE_ID, + 'codex', + '2026-04-10T00:03:00.000Z', + '2026-04-10T00:03:20.000Z', + '2026-04-10T00:03:20.000Z', + 'attempt 3 failed', + ), + ).toThrow(/must preserve contiguous parent lineage/); + } finally { + database.close(); + } + }); + + it('rejects direct inserts when parent_handoff_id does not belong to the previous attempt of the same turn', () => { + const database = new Database(':memory:'); + + try { + initializeDatabaseSchema(database); + + const otherTurnId = + 'other-handoff-turn:2026-04-10T00:00:00.000Z:reviewer-turn'; + insertPairedTurnIdentityRow(database, { + turnId: otherTurnId, + taskId: 'other-handoff-task', + taskUpdatedAt: '2026-04-10T00:00:00.000Z', + role: 'reviewer', + intentKind: 'reviewer-turn', + createdAt: '2026-04-10T00:00:00.000Z', + updatedAt: '2026-04-10T00:00:30.000Z', + }); + database + .prepare( + ` + INSERT INTO paired_turn_attempts ( + attempt_id, + turn_id, + attempt_no, + task_id, + task_updated_at, + role, + intent_kind, + state, + executor_service_id, + executor_agent_type, + created_at, + updated_at, + completed_at, + last_error + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + ) + .run( + buildPairedTurnAttemptId(otherTurnId, 1), + otherTurnId, + 1, + 'other-handoff-task', + '2026-04-10T00:00:00.000Z', + 'reviewer', + 'reviewer-turn', + 'delegated', + CODEX_REVIEW_SERVICE_ID, + 'codex', + '2026-04-10T00:00:00.000Z', + '2026-04-10T00:00:30.000Z', + null, + null, + ); + database + .prepare( + ` + INSERT INTO service_handoffs ( + chat_jid, + group_folder, + paired_task_id, + paired_task_updated_at, + turn_id, + turn_attempt_id, + turn_attempt_no, + turn_intent_kind, + turn_role, + source_service_id, + target_service_id, + source_role, + source_agent_type, + target_role, + target_agent_type, + prompt, + status, + intended_role, + created_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + ) + .run( + 'group@test', + 'test-group', + 'other-handoff-task', + '2026-04-10T00:00:00.000Z', + otherTurnId, + buildPairedTurnAttemptId(otherTurnId, 1), + 1, + 'reviewer-turn', + 'reviewer', + CLAUDE_SERVICE_ID, + CODEX_REVIEW_SERVICE_ID, + 'owner', + 'claude-code', + 'reviewer', + 'codex', + 'other handoff', + 'failed', + 'reviewer', + '2026-04-10T00:00:30.000Z', + ); + const wrongHandoffId = ( + database.prepare('SELECT last_insert_rowid() AS id').get() as { + id: number; + } + ).id; + + const turnIdentity = buildPairedTurnIdentity({ + taskId: 'trigger-parent-handoff-task', + taskUpdatedAt: '2026-04-10T00:00:00.000Z', + role: 'reviewer', + intentKind: 'reviewer-turn', + }); + insertPairedTurnIdentityRow(database, { + turnId: turnIdentity.turnId, + taskId: turnIdentity.taskId, + taskUpdatedAt: turnIdentity.taskUpdatedAt, + role: turnIdentity.role, + intentKind: turnIdentity.intentKind, + createdAt: '2026-04-10T00:00:00.000Z', + updatedAt: '2026-04-10T00:01:00.000Z', + }); + database + .prepare( + ` + INSERT INTO paired_turn_attempts ( + attempt_id, + turn_id, + attempt_no, + task_id, + task_updated_at, + role, + intent_kind, + state, + executor_service_id, + executor_agent_type, + created_at, + updated_at, + completed_at, + last_error + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + ) + .run( + buildPairedTurnAttemptId(turnIdentity.turnId, 1), + turnIdentity.turnId, + 1, + turnIdentity.taskId, + turnIdentity.taskUpdatedAt, + turnIdentity.role, + turnIdentity.intentKind, + 'failed', + CODEX_REVIEW_SERVICE_ID, + 'codex', + '2026-04-10T00:00:00.000Z', + '2026-04-10T00:01:00.000Z', + '2026-04-10T00:01:00.000Z', + 'attempt 1 failed', + ); + + expect(() => + database + .prepare( + ` + INSERT INTO paired_turn_attempts ( + attempt_id, + parent_attempt_id, + parent_handoff_id, + turn_id, + attempt_no, + task_id, + task_updated_at, + role, + intent_kind, + state, + executor_service_id, + executor_agent_type, + created_at, + updated_at, + completed_at, + last_error + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + ) + .run( + buildPairedTurnAttemptId(turnIdentity.turnId, 2), + buildPairedTurnAttemptParentId(turnIdentity.turnId, 2), + wrongHandoffId, + turnIdentity.turnId, + 2, + turnIdentity.taskId, + turnIdentity.taskUpdatedAt, + turnIdentity.role, + turnIdentity.intentKind, + 'running', + CODEX_REVIEW_SERVICE_ID, + 'codex', + '2026-04-10T00:02:00.000Z', + '2026-04-10T00:02:00.000Z', + null, + null, + ), + ).toThrow( + /parent_handoff_id must reference the previous attempt handoff of the same turn/, + ); + } finally { + database.close(); + } + }); + + it('rejects direct inserts when parent_handoff_id points to a completed previous-attempt handoff', () => { + const database = new Database(':memory:'); + + try { + initializeDatabaseSchema(database); + + const turnIdentity = buildPairedTurnIdentity({ + taskId: 'trigger-completed-parent-handoff-task', + taskUpdatedAt: '2026-04-10T00:00:00.000Z', + role: 'reviewer', + intentKind: 'reviewer-turn', + }); + insertPairedTurnIdentityRow(database, { + turnId: turnIdentity.turnId, + taskId: turnIdentity.taskId, + taskUpdatedAt: turnIdentity.taskUpdatedAt, + role: turnIdentity.role, + intentKind: turnIdentity.intentKind, + createdAt: '2026-04-10T00:00:00.000Z', + updatedAt: '2026-04-10T00:01:00.000Z', + }); + database + .prepare( + ` + INSERT INTO paired_turn_attempts ( + attempt_id, + turn_id, + attempt_no, + task_id, + task_updated_at, + role, + intent_kind, + state, + executor_service_id, + executor_agent_type, + created_at, + updated_at, + completed_at, + last_error + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + ) + .run( + buildPairedTurnAttemptId(turnIdentity.turnId, 1), + turnIdentity.turnId, + 1, + turnIdentity.taskId, + turnIdentity.taskUpdatedAt, + turnIdentity.role, + turnIdentity.intentKind, + 'completed', + CODEX_REVIEW_SERVICE_ID, + 'codex', + '2026-04-10T00:00:00.000Z', + '2026-04-10T00:01:00.000Z', + '2026-04-10T00:01:00.000Z', + null, + ); + database + .prepare( + ` + INSERT INTO service_handoffs ( + chat_jid, + group_folder, + paired_task_id, + paired_task_updated_at, + turn_id, + turn_attempt_id, + turn_attempt_no, + turn_intent_kind, + turn_role, + source_service_id, + target_service_id, + source_role, + source_agent_type, + target_role, + target_agent_type, + prompt, + status, + intended_role, + created_at, + completed_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + ) + .run( + 'group@test', + 'test-group', + turnIdentity.taskId, + turnIdentity.taskUpdatedAt, + turnIdentity.turnId, + buildPairedTurnAttemptId(turnIdentity.turnId, 1), + 1, + turnIdentity.intentKind, + turnIdentity.role, + CLAUDE_SERVICE_ID, + CODEX_REVIEW_SERVICE_ID, + 'owner', + 'claude-code', + turnIdentity.role, + 'codex', + 'completed handoff cannot seed retry lineage', + 'completed', + turnIdentity.role, + '2026-04-10T00:00:20.000Z', + '2026-04-10T00:00:30.000Z', + ); + const completedHandoffId = ( + database.prepare('SELECT last_insert_rowid() AS id').get() as { + id: number; + } + ).id; + + expect(() => + database + .prepare( + ` + INSERT INTO paired_turn_attempts ( + attempt_id, + parent_attempt_id, + parent_handoff_id, + turn_id, + attempt_no, + task_id, + task_updated_at, + role, + intent_kind, + state, + executor_service_id, + executor_agent_type, + created_at, + updated_at, + completed_at, + last_error + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + ) + .run( + buildPairedTurnAttemptId(turnIdentity.turnId, 2), + buildPairedTurnAttemptParentId(turnIdentity.turnId, 2), + completedHandoffId, + turnIdentity.turnId, + 2, + turnIdentity.taskId, + turnIdentity.taskUpdatedAt, + turnIdentity.role, + turnIdentity.intentKind, + 'running', + CODEX_REVIEW_SERVICE_ID, + 'codex', + '2026-04-10T00:02:00.000Z', + '2026-04-10T00:02:00.000Z', + null, + null, + ), + ).toThrow( + /parent_handoff_id must reference the previous attempt handoff of the same turn/, + ); + } finally { + database.close(); + } + }); + + it('rejects direct inserts when continuation_handoff_id does not belong to the same attempt', () => { + const database = new Database(':memory:'); + + try { + initializeDatabaseSchema(database); + + const otherTurnId = + 'other-continuation-handoff-turn:2026-04-10T00:00:00.000Z:reviewer-turn'; + insertPairedTurnIdentityRow(database, { + turnId: otherTurnId, + taskId: 'other-continuation-handoff-task', + taskUpdatedAt: '2026-04-10T00:00:00.000Z', + role: 'reviewer', + intentKind: 'reviewer-turn', + createdAt: '2026-04-10T00:00:00.000Z', + updatedAt: '2026-04-10T00:00:30.000Z', + }); + database + .prepare( + ` + INSERT INTO paired_turn_attempts ( + attempt_id, + turn_id, + attempt_no, + task_id, + task_updated_at, + role, + intent_kind, + state, + executor_service_id, + executor_agent_type, + created_at, + updated_at, + completed_at, + last_error + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + ) + .run( + buildPairedTurnAttemptId(otherTurnId, 1), + otherTurnId, + 1, + 'other-continuation-handoff-task', + '2026-04-10T00:00:00.000Z', + 'reviewer', + 'reviewer-turn', + 'delegated', + CODEX_REVIEW_SERVICE_ID, + 'codex', + '2026-04-10T00:00:00.000Z', + '2026-04-10T00:00:30.000Z', + null, + null, + ); + database + .prepare( + ` + INSERT INTO service_handoffs ( + chat_jid, + group_folder, + paired_task_id, + paired_task_updated_at, + turn_id, + turn_attempt_id, + turn_attempt_no, + turn_intent_kind, + turn_role, + source_service_id, + target_service_id, + source_role, + source_agent_type, + target_role, + target_agent_type, + prompt, + status, + intended_role, + created_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + ) + .run( + 'group@test', + 'test-group', + 'other-continuation-handoff-task', + '2026-04-10T00:00:00.000Z', + otherTurnId, + buildPairedTurnAttemptId(otherTurnId, 1), + 1, + 'reviewer-turn', + 'reviewer', + CLAUDE_SERVICE_ID, + CODEX_REVIEW_SERVICE_ID, + 'owner', + 'claude-code', + 'reviewer', + 'codex', + 'other continuation handoff', + 'claimed', + 'reviewer', + '2026-04-10T00:00:20.000Z', + ); + const wrongContinuationHandoffId = ( + database.prepare('SELECT last_insert_rowid() AS id').get() as { + id: number; + } + ).id; + + const turnIdentity = buildPairedTurnIdentity({ + taskId: 'trigger-continuation-handoff-task', + taskUpdatedAt: '2026-04-10T00:00:00.000Z', + role: 'reviewer', + intentKind: 'reviewer-turn', + }); + insertPairedTurnIdentityRow(database, { + turnId: turnIdentity.turnId, + taskId: turnIdentity.taskId, + taskUpdatedAt: turnIdentity.taskUpdatedAt, + role: turnIdentity.role, + intentKind: turnIdentity.intentKind, + createdAt: '2026-04-10T00:00:00.000Z', + updatedAt: '2026-04-10T00:01:00.000Z', + }); + + expect(() => + database + .prepare( + ` + INSERT INTO paired_turn_attempts ( + attempt_id, + continuation_handoff_id, + turn_id, + attempt_no, + task_id, + task_updated_at, + role, + intent_kind, + state, + executor_service_id, + executor_agent_type, + created_at, + updated_at, + completed_at, + last_error + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + ) + .run( + buildPairedTurnAttemptId(turnIdentity.turnId, 1), + wrongContinuationHandoffId, + turnIdentity.turnId, + 1, + turnIdentity.taskId, + turnIdentity.taskUpdatedAt, + turnIdentity.role, + turnIdentity.intentKind, + 'running', + CODEX_REVIEW_SERVICE_ID, + 'codex', + '2026-04-10T00:01:00.000Z', + '2026-04-10T00:01:00.000Z', + null, + null, + ), + ).toThrow( + /continuation_handoff_id must reference a handoff of the same attempt/, + ); + } finally { + database.close(); + } + }); }); describe('message seq cursors', () => { diff --git a/src/db.ts b/src/db.ts index 8f870ee..0f7470a 100644 --- a/src/db.ts +++ b/src/db.ts @@ -1,5 +1,6 @@ import { Database } from 'bun:sqlite'; import fs from 'fs'; +import path from 'path'; import { ASSISTANT_NAME, @@ -7,6 +8,7 @@ import { CLAUDE_SERVICE_ID, CODEX_MAIN_SERVICE_ID, CODEX_REVIEW_SERVICE_ID, + DATA_DIR, normalizeServiceId, OWNER_AGENT_TYPE, REVIEWER_AGENT_TYPE, @@ -23,29 +25,33 @@ import { logger } from './logger.js'; import { type RoomModeSource, type RoomRegistrationSnapshot, + countPendingLegacyRegisteredGroupRows, type StoredRoomSettings, buildRegisteredGroupFromStoredSettings, + buildLegacyRoomMigrationPlan, collectRegisteredAgentTypes, collectRegisteredAgentTypesForFolder, collectRoomRegistrationSnapshot, - getLegacyRegisteredGroup, - getLegacyRegisteredGroupRows, + deleteLegacyRegisteredGroupRowsForJid, + getPendingLegacyRegisteredGroupJids as getPendingLegacyRegisteredGroupJidsFromDatabase, getStoredRoomRowsFromDatabase, getStoredRoomSettingsRowFromDatabase, + inferStoredRoomCapabilityTypes, inferOwnerAgentTypeFromRegisteredAgentTypes, inferRoomModeFromRegisteredAgentTypes, insertStoredRoomSettings, + insertStoredRoomSettingsFromMigration, materializeRegisteredGroupsForRoom, normalizeRoomModeSource, normalizeStoredAgentType, - parseRegisteredGroupRow, resolveStoredRoomCapabilityTypes, resolveAssignedRoomFolder, + syncRoomRoleOverridesForRoom, + upsertRoomRoleOverride, updateStoredRoomMetadata, } from './db/room-registration.js'; import { initializeDatabaseSchema, - migrateJsonStateFromFiles, openDatabaseFromFile, openInMemoryDatabase, openPersistentDatabase, @@ -119,6 +125,20 @@ import { upsertPairedProjectInDatabase, upsertPairedWorkspaceInDatabase, } from './db/paired-state.js'; +import { + clearPairedTurnAttemptsInDatabase, + getPairedTurnAttemptsForTurnFromDatabase, + type PairedTurnAttemptRecord, +} from './db/paired-turn-attempts.js'; +import { + clearPairedTurnsInDatabase, + completePairedTurnInDatabase, + failPairedTurnInDatabase, + getPairedTurnByIdFromDatabase, + getPairedTurnsForTaskFromDatabase, + markPairedTurnRunningInDatabase, + type PairedTurnRecord, +} from './db/paired-turns.js'; import { getLatestTurnNumberFromDatabase, getPairedTurnOutputsFromDatabase, @@ -138,15 +158,12 @@ import { } from './db/service-handoffs.js'; import { getLastRespondingAgentTypeFromDatabase, + getLegacyRouterStateKeysFromDatabase, getRouterStateForServiceFromDatabase, getRouterStateFromDatabase, setRouterStateForServiceInDatabase, setRouterStateInDatabase, } from './db/router-state.js'; -import { - resolveStablePairedTaskOwnerAgentType, - resolveStableRoomRoleAgentType, -} from './db/legacy-rebuilds.js'; import { deleteAllSessionsForGroupFromDatabase, deleteSessionFromDatabase, @@ -219,6 +236,8 @@ export type { MemorySourceKind, RecallMemoryQuery, } from './db/memories.js'; +export type { PairedTurnAttemptRecord } from './db/paired-turn-attempts.js'; +export type { PairedTurnRecord } from './db/paired-turns.js'; export type { ChatInfo } from './db/messages.js'; export type { WorkItem } from './db/work-items.js'; export type { ChannelOwnerLeaseRow } from './db/channel-owner-leases.js'; @@ -227,24 +246,20 @@ export type { ServiceHandoff } from './db/service-handoffs.js'; export function initDatabase(): void { db = openPersistentDatabase(); initializeDatabaseSchema(db); - syncLegacyRegisteredGroupsIntoStoredRooms(); + assertNoPendingLegacyRoomMigration(); + assertNoPendingLegacyJsonStateMigration(); + assertNoPendingLegacyRouterStateDbMigration(); clearPairedTaskExecutionLeasesForServiceInDatabase( db, normalizeServiceId(SERVICE_ID), ); clearExpiredPairedTaskExecutionLeasesInDatabase(db); - migrateJsonStateFromFiles({ - setRouterState, - setSession, - writeLegacyRegisteredGroupAndSyncRoomSettings, - }); } /** @internal - for tests only. Creates a fresh in-memory database. */ export function _initTestDatabase(): void { db = openInMemoryDatabase(); initializeDatabaseSchema(db); - syncLegacyRegisteredGroupsIntoStoredRooms(); clearPairedTaskExecutionLeasesForServiceInDatabase( db, normalizeServiceId(SERVICE_ID), @@ -256,7 +271,8 @@ export function _initTestDatabase(): void { export function _initTestDatabaseFromFile(dbPath: string): void { db = openDatabaseFromFile(dbPath); initializeDatabaseSchema(db); - syncLegacyRegisteredGroupsIntoStoredRooms(); + assertNoPendingLegacyRoomMigration(); + assertNoPendingLegacyRouterStateDbMigration(); clearPairedTaskExecutionLeasesForServiceInDatabase( db, normalizeServiceId(SERVICE_ID), @@ -278,6 +294,15 @@ export function _setStoredRoomOwnerAgentTypeForTests( updated_at = ? WHERE chat_jid = ?`, ).run(ownerAgentType, new Date().toISOString(), chatJid); + if (ownerAgentType) { + const now = new Date().toISOString(); + upsertRoomRoleOverride(db, chatJid, { + role: 'owner', + agentType: ownerAgentType, + createdAt: now, + updatedAt: now, + }); + } } /** @internal - for tests only. */ @@ -733,7 +758,7 @@ export function getRegisteredGroup( requestedAgentType, ); } - return getLegacyRegisteredGroup(db, jid, agentType); + return undefined; } function writeLegacyRegisteredGroupAndSyncRoomSettings( @@ -792,6 +817,14 @@ function writeLegacyRegisteredGroupAndSyncRoomSettings( seededAgentType === ownerAgentType ? group.agentConfig : undefined, group.added_at, ); + syncRoomRoleOverridesForRoom( + db, + jid, + roomMode, + ownerAgentType, + seededAgentType === ownerAgentType ? group.agentConfig : undefined, + group.added_at, + ); }); tx(); } @@ -807,6 +840,34 @@ export function _setRegisteredGroupForTests( writeLegacyRegisteredGroupAndSyncRoomSettings(jid, group); } +export function _migrateLegacyRoomRegistrationsForTests(): { + migratedRooms: number; + migratedRoleOverrides: number; +} { + if (!db) { + throw new Error('Database not initialized'); + } + + const jids = getPendingLegacyRegisteredGroupJidsFromDatabase(db); + let migratedRooms = 0; + let migratedRoleOverrides = 0; + + db.transaction(() => { + for (const jid of jids) { + const plan = buildLegacyRoomMigrationPlan(db, jid); + if (!plan) continue; + insertStoredRoomSettingsFromMigration(db, plan); + for (const override of plan.roleOverrides) { + upsertRoomRoleOverride(db, jid, override); + migratedRoleOverrides += 1; + } + migratedRooms += 1; + } + })(); + + return { migratedRooms, migratedRoleOverrides }; +} + export function assignRoom( chatJid: string, input: AssignRoomInput, @@ -863,15 +924,15 @@ export function assignRoom( insertStoredRoomSettings(db, chatJid, roomMode, 'explicit', snapshot); } - materializeRegisteredGroupsForRoom( + syncRoomRoleOverridesForRoom( db, chatJid, - snapshot, roomMode, ownerAgentType, input.ownerAgentConfig, input.addedAt ?? now, ); + deleteLegacyRegisteredGroupRowsForJid(db, chatJid); })(); return getRegisteredGroup(chatJid); @@ -894,13 +955,7 @@ export function updateRegisteredGroupName(jid: string, name: string): void { plan.snapshot, ); } - materializeRegisteredGroupsForRoom( - db, - jid, - plan.snapshot, - plan.roomMode, - plan.snapshot.ownerAgentType, - ); + deleteLegacyRegisteredGroupRowsForJid(db, jid); })(); } @@ -923,15 +978,6 @@ export function getAllRegisteredGroups( } } - for (const legacyRow of getLegacyRegisteredGroupRows(db, agentTypeFilter)) { - if (result[legacyRow.jid]) continue; - const group = parseRegisteredGroupRow(legacyRow); - if (group) { - const { jid, ...rest } = group; - result[jid] = rest; - } - } - return result; } @@ -997,23 +1043,42 @@ 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) { +function assertNoPendingLegacyRoomMigration(): void { + const pendingLegacyRows = countPendingLegacyRegisteredGroupRows(db); + const hasLegacyRegisteredGroupsJson = fs.existsSync( + path.join(DATA_DIR, 'registered_groups.json'), + ); + if (pendingLegacyRows === 0 && !hasLegacyRegisteredGroupsJson) { return; } - db.transaction((registeredGroupRows: Array<{ jid: string }>) => { - for (const row of registeredGroupRows) { - syncStoredRoomRegistrationSnapshotForJid(row.jid); - } - })(rows); + throw new Error( + `Legacy room migration required before startup (pending_rows=${pendingLegacyRows}, legacy_json=${hasLegacyRegisteredGroupsJson})`, + ); +} + +function assertNoPendingLegacyJsonStateMigration(): void { + const pendingFiles = ['router_state.json', 'sessions.json'].filter( + (filename) => fs.existsSync(path.join(DATA_DIR, filename)), + ); + if (pendingFiles.length === 0) { + return; + } + + throw new Error( + `Legacy JSON state migration required before startup (files=${pendingFiles.join(',')})`, + ); +} + +function assertNoPendingLegacyRouterStateDbMigration(): void { + const legacyKeys = getLegacyRouterStateKeysFromDatabase(db); + if (legacyKeys.length === 0) { + return; + } + + throw new Error( + `Legacy router_state DB migration required before startup (keys=${legacyKeys.join(',')})`, + ); } function buildRoomRegistrationSnapshotFromStoredRoom( @@ -1103,12 +1168,17 @@ export function getExplicitRoomMode(chatJid: string): RoomMode | undefined { export function setExplicitRoomMode(chatJid: string, roomMode: RoomMode): void { upsertStoredRoomMode(chatJid, roomMode, 'explicit'); + deleteLegacyRegisteredGroupRowsForJid(db, chatJid); } export function clearExplicitRoomMode(chatJid: string): void { - const agentTypes = collectRegisteredAgentTypes(db, chatJid); + const stored = getStoredRoomSettingsRowFromDatabase(db, chatJid); + const agentTypes = stored + ? inferStoredRoomCapabilityTypes(db, stored) + : collectRegisteredAgentTypes(db, chatJid); if (agentTypes.length === 0) { db.prepare('DELETE FROM room_settings WHERE chat_jid = ?').run(chatJid); + deleteLegacyRegisteredGroupRowsForJid(db, chatJid); return; } upsertStoredRoomMode( @@ -1116,6 +1186,7 @@ export function clearExplicitRoomMode(chatJid: string): void { inferRoomModeFromRegisteredAgentTypes(agentTypes), 'inferred', ); + deleteLegacyRegisteredGroupRowsForJid(db, chatJid); } export function getEffectiveRoomMode(chatJid: string): RoomMode { @@ -1232,6 +1303,44 @@ export function refreshPairedTaskExecutionLease(args: { return refreshPairedTaskExecutionLeaseInDatabase(db, args); } +export function markPairedTurnRunning(args: { + turnIdentity: import('./paired-turn-identity.js').PairedTurnIdentity; + executorServiceId?: string | null; + executorAgentType?: AgentType | null; + runId?: string | null; +}): void { + markPairedTurnRunningInDatabase(db, args); +} + +export function completePairedTurn( + turnIdentity: import('./paired-turn-identity.js').PairedTurnIdentity, +): void { + completePairedTurnInDatabase(db, turnIdentity); +} + +export function failPairedTurn(args: { + turnIdentity: import('./paired-turn-identity.js').PairedTurnIdentity; + error?: string | null; +}): void { + failPairedTurnInDatabase(db, args); +} + +export function getPairedTurnById( + turnId: string, +): PairedTurnRecord | undefined { + return getPairedTurnByIdFromDatabase(db, turnId); +} + +export function getPairedTurnsForTask(taskId: string): PairedTurnRecord[] { + return getPairedTurnsForTaskFromDatabase(db, taskId); +} + +export function getPairedTurnAttempts( + turnId: string, +): PairedTurnAttemptRecord[] { + return getPairedTurnAttemptsForTurnFromDatabase(db, turnId); +} + export function upsertPairedWorkspace(workspace: PairedWorkspace): void { upsertPairedWorkspaceInDatabase(db, workspace); } @@ -1254,6 +1363,8 @@ export function _clearPairedTurnReservationsForTests(): void { } clearPairedTurnReservationsInDatabase(db); clearPairedTaskExecutionLeasesInDatabase(db); + clearPairedTurnAttemptsInDatabase(db); + clearPairedTurnsInDatabase(db); } /** diff --git a/src/db/base-schema.ts b/src/db/base-schema.ts index a712b9f..1090ce1 100644 --- a/src/db/base-schema.ts +++ b/src/db/base-schema.ts @@ -178,6 +178,10 @@ export function applyBaseSchema(database: Database): void { task_status TEXT NOT NULL, round_trip_count INTEGER NOT NULL DEFAULT 0, task_updated_at TEXT NOT NULL, + turn_id TEXT NOT NULL, + turn_attempt_id TEXT, + turn_attempt_no INTEGER, + turn_role TEXT NOT NULL, intent_kind TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'pending', scheduled_run_id TEXT, @@ -186,7 +190,11 @@ export function applyBaseSchema(database: Database): void { updated_at TEXT NOT NULL, consumed_at TEXT, PRIMARY KEY (chat_jid, task_id, task_updated_at, intent_kind), + FOREIGN KEY (turn_id, turn_attempt_no) + REFERENCES paired_turn_attempts(turn_id, attempt_no) + ON DELETE CASCADE, CHECK (status IN ('pending', 'completed')), + CHECK (turn_role IN ('owner', 'reviewer', 'arbiter')), CHECK ( intent_kind IN ( 'owner-turn', @@ -199,10 +207,89 @@ export function applyBaseSchema(database: Database): void { ); CREATE INDEX IF NOT EXISTS idx_paired_turn_reservations_task ON paired_turn_reservations(task_id, task_updated_at, status); + CREATE TABLE IF NOT EXISTS paired_turns ( + turn_id TEXT PRIMARY KEY, + task_id TEXT NOT NULL, + task_updated_at TEXT NOT NULL, + role TEXT NOT NULL, + intent_kind TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + CHECK (role IN ('owner', 'reviewer', 'arbiter')), + CHECK ( + intent_kind IN ( + 'owner-turn', + 'reviewer-turn', + 'arbiter-turn', + 'owner-follow-up', + 'finalize-owner-turn' + ) + ) + ); + CREATE INDEX IF NOT EXISTS idx_paired_turns_task + ON paired_turns(task_id, task_updated_at, updated_at); + CREATE TABLE IF NOT EXISTS paired_turn_attempts ( + attempt_id TEXT NOT NULL PRIMARY KEY, + parent_attempt_id TEXT, + parent_handoff_id INTEGER, + continuation_handoff_id INTEGER, + turn_id TEXT NOT NULL, + attempt_no INTEGER NOT NULL, + task_id TEXT NOT NULL, + task_updated_at TEXT NOT NULL, + role TEXT NOT NULL, + intent_kind TEXT NOT NULL, + state TEXT NOT NULL, + executor_service_id TEXT, + executor_agent_type TEXT, + active_run_id TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + completed_at TEXT, + last_error TEXT, + UNIQUE (turn_id, attempt_no), + FOREIGN KEY (parent_attempt_id) + REFERENCES paired_turn_attempts(attempt_id) + ON DELETE CASCADE, + FOREIGN KEY (parent_handoff_id) + REFERENCES service_handoffs(id) + ON DELETE SET NULL, + FOREIGN KEY (continuation_handoff_id) + REFERENCES service_handoffs(id) + ON DELETE SET NULL, + FOREIGN KEY (turn_id) REFERENCES paired_turns(turn_id) ON DELETE CASCADE, + CHECK (role IN ('owner', 'reviewer', 'arbiter')), + CHECK ( + intent_kind IN ( + 'owner-turn', + 'reviewer-turn', + 'arbiter-turn', + 'owner-follow-up', + 'finalize-owner-turn' + ) + ), + CHECK ( + state IN ( + 'running', + 'delegated', + 'completed', + 'failed', + 'cancelled' + ) + ), + CHECK (executor_agent_type IN ('claude-code', 'codex') OR executor_agent_type IS NULL) + ); + CREATE INDEX IF NOT EXISTS idx_paired_turn_attempts_turn + ON paired_turn_attempts(turn_id, attempt_no); + CREATE INDEX IF NOT EXISTS idx_paired_turn_attempts_task + ON paired_turn_attempts(task_id, task_updated_at, attempt_no); CREATE TABLE IF NOT EXISTS paired_task_execution_leases ( task_id TEXT PRIMARY KEY, chat_jid TEXT NOT NULL, role TEXT NOT NULL, + turn_id TEXT NOT NULL, + turn_attempt_id TEXT, + turn_attempt_no INTEGER, intent_kind TEXT NOT NULL, claimed_run_id TEXT NOT NULL, claimed_service_id TEXT NOT NULL, @@ -211,6 +298,9 @@ export function applyBaseSchema(database: Database): void { claimed_at TEXT NOT NULL, updated_at TEXT NOT NULL, expires_at TEXT NOT NULL, + FOREIGN KEY (turn_id, turn_attempt_no) + REFERENCES paired_turn_attempts(turn_id, attempt_no) + ON DELETE CASCADE, CHECK (role IN ('owner', 'reviewer', 'arbiter')), CHECK ( intent_kind IN ( @@ -249,16 +339,35 @@ export function applyBaseSchema(database: Database): void { is_main INTEGER DEFAULT 0, owner_agent_type TEXT, work_dir TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL, CHECK (room_mode IN ('single', 'tribunal')), CHECK (owner_agent_type IN ('claude-code', 'codex') OR owner_agent_type IS NULL) ); + CREATE TABLE IF NOT EXISTS room_role_overrides ( + chat_jid TEXT NOT NULL, + role TEXT NOT NULL, + agent_type TEXT NOT NULL, + agent_config_json TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (chat_jid, role), + CHECK (role IN ('owner', 'reviewer', 'arbiter')), + CHECK (agent_type IN ('claude-code', 'codex')) + ); CREATE TABLE IF NOT EXISTS service_handoffs ( id INTEGER PRIMARY KEY AUTOINCREMENT, chat_jid TEXT NOT NULL, group_folder TEXT NOT NULL, source_service_id TEXT NOT NULL, target_service_id TEXT NOT NULL, + paired_task_id TEXT, + paired_task_updated_at TEXT, + turn_id TEXT, + turn_attempt_id TEXT, + turn_attempt_no INTEGER, + turn_intent_kind TEXT, + turn_role TEXT, source_role TEXT, source_agent_type TEXT, target_role TEXT, @@ -273,8 +382,21 @@ export function applyBaseSchema(database: Database): void { claimed_at TEXT, completed_at TEXT, last_error TEXT, + FOREIGN KEY (turn_id, turn_attempt_no) + REFERENCES paired_turn_attempts(turn_id, attempt_no) + ON DELETE CASCADE, CHECK (status IN ('pending', 'claimed', 'completed', 'failed')), CHECK (intended_role IN ('owner', 'reviewer', 'arbiter') OR intended_role IS NULL), + CHECK ( + turn_intent_kind IN ( + 'owner-turn', + 'reviewer-turn', + 'arbiter-turn', + 'owner-follow-up', + 'finalize-owner-turn' + ) OR turn_intent_kind IS NULL + ), + CHECK (turn_role IN ('owner', 'reviewer', 'arbiter') OR turn_role IS NULL), CHECK (source_role IN ('owner', 'reviewer', 'arbiter') OR source_role IS NULL), CHECK (target_role IN ('owner', 'reviewer', 'arbiter') OR target_role IS NULL) ); diff --git a/src/db/bootstrap.ts b/src/db/bootstrap.ts index e7c163f..d836f7e 100644 --- a/src/db/bootstrap.ts +++ b/src/db/bootstrap.ts @@ -2,27 +2,41 @@ import { Database } from 'bun:sqlite'; import fs from 'fs'; import path from 'path'; -import { ASSISTANT_NAME, DATA_DIR, STORE_DIR } from '../config.js'; -import { logger } from '../logger.js'; -import type { RegisteredGroup } from '../types.js'; -import { readJsonFile } from '../utils.js'; +import { ASSISTANT_NAME, STORE_DIR } from '../config.js'; import { applyBaseSchema } from './base-schema.js'; import { applySchemaMigrations } from './schema.js'; -export interface JsonStateMigrationHooks { - setRouterState(key: string, value: string): void; - setSession(groupFolder: string, sessionId: string): void; - writeLegacyRegisteredGroupAndSyncRoomSettings( - jid: string, - group: RegisteredGroup, - ): void; +function setForeignKeys(database: Database, enabled: boolean): void { + database.exec(`PRAGMA foreign_keys = ${enabled ? 'ON' : 'OFF'}`); +} + +function assertNoForeignKeyViolations(database: Database): void { + const violations = database + .prepare('PRAGMA foreign_key_check') + .all() as Array<{ + table: string; + rowid: number; + parent: string; + fkid: number; + }>; + if (violations.length === 0) { + return; + } + + const violation = violations[0]!; + throw new Error( + `Foreign key integrity check failed for ${violation.table}(rowid=${violation.rowid}) referencing ${violation.parent} [fk=${violation.fkid}]`, + ); } export function initializeDatabaseSchema(database: Database): void { + setForeignKeys(database, false); applyBaseSchema(database); applySchemaMigrations(database, { assistantName: ASSISTANT_NAME, }); + setForeignKeys(database, true); + assertNoForeignKeyViolations(database); } export function openPersistentDatabase(): Database { @@ -42,62 +56,3 @@ export function openInMemoryDatabase(): Database { export function openDatabaseFromFile(dbPath: string): Database { return new Database(dbPath); } - -export function migrateJsonStateFromFiles( - hooks: JsonStateMigrationHooks, -): void { - const migrateFile = (filename: string) => { - const filePath = path.join(DATA_DIR, filename); - const data = readJsonFile(filePath); - if (data === null) return null; - try { - fs.renameSync(filePath, `${filePath}.migrated`); - } catch { - /* best effort */ - } - return data; - }; - - const routerState = migrateFile('router_state.json') as { - last_timestamp?: string; - last_agent_timestamp?: Record; - } | null; - if (routerState) { - if (routerState.last_timestamp) { - hooks.setRouterState('last_timestamp', routerState.last_timestamp); - } - if (routerState.last_agent_timestamp) { - hooks.setRouterState( - 'last_agent_timestamp', - JSON.stringify(routerState.last_agent_timestamp), - ); - } - } - - const sessions = migrateFile('sessions.json') as Record< - string, - string - > | null; - if (sessions) { - for (const [folder, sessionId] of Object.entries(sessions)) { - hooks.setSession(folder, sessionId); - } - } - - const groups = migrateFile('registered_groups.json') as Record< - string, - RegisteredGroup - > | null; - if (groups) { - for (const [jid, group] of Object.entries(groups)) { - try { - hooks.writeLegacyRegisteredGroupAndSyncRoomSettings(jid, group); - } catch (err) { - logger.warn( - { jid, folder: group.folder, err }, - 'Skipping migrated registered group with invalid folder', - ); - } - } - } -} diff --git a/src/db/canonical-role-metadata.ts b/src/db/canonical-role-metadata.ts new file mode 100644 index 0000000..4915f80 --- /dev/null +++ b/src/db/canonical-role-metadata.ts @@ -0,0 +1,374 @@ +import { ARBITER_AGENT_TYPE, SERVICE_SESSION_SCOPE } from '../config.js'; +import { + inferAgentTypeFromServiceShadow, + inferRoleFromServiceShadow, + resolveRoleServiceShadow, +} from '../role-service-shadow.js'; +import type { AgentType, PairedRoomRole } from '../types.js'; +import { normalizeStoredAgentType } from './room-registration.js'; + +interface RequiredRoleMetadataInput { + context: string; + role: PairedRoomRole; + storedAgentType?: string | null; + storedServiceId?: string | null; +} + +interface OptionalRoleMetadataInput extends RequiredRoleMetadataInput {} + +function resolveRequiredAgentType(input: RequiredRoleMetadataInput): AgentType { + const persistedAgentType = normalizeStoredAgentType(input.storedAgentType); + const inferredAgentType = inferAgentTypeFromServiceShadow( + input.storedServiceId, + ); + if ( + persistedAgentType && + inferredAgentType && + persistedAgentType !== inferredAgentType + ) { + throw new Error( + `${input.context}: ${input.role}_agent_type conflicts with ${input.role}_service_id`, + ); + } + + const agentType = persistedAgentType ?? inferredAgentType; + if (agentType) { + return agentType; + } + + throw new Error( + `${input.context}: cannot resolve ${input.role}_agent_type from stored row metadata`, + ); +} + +function resolveRequiredServiceId( + context: string, + role: PairedRoomRole, + agentType: AgentType, + storedServiceId?: string | null, +): string { + return ( + storedServiceId ?? + resolveRoleServiceShadow(role, agentType) ?? + (() => { + throw new Error( + `${context}: cannot resolve ${role}_service_id from stored row metadata`, + ); + })() + ); +} + +function resolveOptionalRoleMetadata(input: OptionalRoleMetadataInput): { + agentType: AgentType | null; + serviceId: string | null; +} { + if (!input.storedAgentType && !input.storedServiceId) { + return { agentType: null, serviceId: null }; + } + + const agentType = resolveRequiredAgentType(input); + return { + agentType, + serviceId: resolveRequiredServiceId( + input.context, + input.role, + agentType, + input.storedServiceId, + ), + }; +} + +export interface CanonicalPairedTaskMetadata { + ownerAgentType: AgentType; + reviewerAgentType: AgentType; + arbiterAgentType: AgentType | null; + ownerServiceId: string; + reviewerServiceId: string; +} + +export function canonicalizePairedTaskMetadata(input: { + id: string; + 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; +}): CanonicalPairedTaskMetadata { + const context = `paired_tasks(${input.id})`; + const ownerAgentType = resolveRequiredAgentType({ + context, + role: 'owner', + storedAgentType: input.owner_agent_type, + storedServiceId: input.owner_service_id, + }); + const reviewerAgentType = resolveRequiredAgentType({ + context, + role: 'reviewer', + storedAgentType: input.reviewer_agent_type, + storedServiceId: input.reviewer_service_id, + }); + + return { + ownerAgentType, + reviewerAgentType, + arbiterAgentType: + normalizeStoredAgentType(input.arbiter_agent_type) ?? + ARBITER_AGENT_TYPE ?? + null, + ownerServiceId: resolveRequiredServiceId( + context, + 'owner', + ownerAgentType, + input.owner_service_id, + ), + reviewerServiceId: resolveRequiredServiceId( + context, + 'reviewer', + reviewerAgentType, + input.reviewer_service_id, + ), + }; +} + +export interface CanonicalChannelOwnerLeaseMetadata { + ownerAgentType: AgentType; + reviewerAgentType: AgentType | null; + arbiterAgentType: AgentType | null; + ownerServiceId: string; + reviewerServiceId: string | null; + arbiterServiceId: string | null; +} + +export function canonicalizeChannelOwnerLeaseMetadata(input: { + 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; +}): CanonicalChannelOwnerLeaseMetadata { + const context = `channel_owner(${input.chat_jid})`; + const ownerAgentType = resolveRequiredAgentType({ + context, + role: 'owner', + storedAgentType: input.owner_agent_type, + storedServiceId: input.owner_service_id, + }); + const reviewer = resolveOptionalRoleMetadata({ + context, + role: 'reviewer', + storedAgentType: input.reviewer_agent_type, + storedServiceId: input.reviewer_service_id, + }); + const arbiter = resolveOptionalRoleMetadata({ + context, + role: 'arbiter', + storedAgentType: input.arbiter_agent_type, + storedServiceId: input.arbiter_service_id, + }); + + return { + ownerAgentType, + reviewerAgentType: reviewer.agentType, + arbiterAgentType: arbiter.agentType, + ownerServiceId: resolveRequiredServiceId( + context, + 'owner', + ownerAgentType, + input.owner_service_id, + ), + reviewerServiceId: reviewer.serviceId, + arbiterServiceId: arbiter.serviceId, + }; +} + +function normalizeHandoffRole( + role: string | null | undefined, +): PairedRoomRole | null { + return role === 'owner' || role === 'reviewer' || role === 'arbiter' + ? role + : null; +} + +function assertStoredRoleMatchesServiceShadow(args: { + context: string; + storedRole: PairedRoomRole | null; + agentType: AgentType | null; + serviceId: string | null; + roleField: 'source_role' | 'target_role'; + serviceField: 'source_service_id' | 'target_service_id'; +}): void { + if (!args.storedRole || !args.agentType || !args.serviceId) { + return; + } + + const inferredRole = inferRoleFromServiceShadow( + args.agentType, + args.serviceId, + ); + if (inferredRole && inferredRole !== args.storedRole) { + throw new Error( + `${args.context}: ${args.roleField} conflicts with ${args.serviceField}`, + ); + } +} + +export interface CanonicalServiceHandoffMetadata { + sourceRole: PairedRoomRole | null; + targetRole: PairedRoomRole | null; + sourceAgentType: AgentType | null; + targetAgentType: AgentType; + sourceServiceId: string; + targetServiceId: string; +} + +export function canonicalizeServiceHandoffMetadata(input: { + id: number | string; + chat_jid: string; + source_service_id?: string | null; + target_service_id?: string | null; + source_role?: string | null; + target_role?: string | null; + intended_role?: string | null; + source_agent_type?: string | null; + target_agent_type?: string | null; +}): CanonicalServiceHandoffMetadata { + const context = `service_handoffs(${input.id})`; + const sourceRole = + normalizeHandoffRole(input.source_role) ?? + normalizeHandoffRole(input.intended_role); + const targetRole = + normalizeHandoffRole(input.target_role) ?? + normalizeHandoffRole(input.intended_role); + const storedSourceAgentType = normalizeStoredAgentType( + input.source_agent_type, + ); + const inferredSourceAgentType = inferAgentTypeFromServiceShadow( + input.source_service_id, + ); + if ( + storedSourceAgentType && + inferredSourceAgentType && + storedSourceAgentType !== inferredSourceAgentType + ) { + throw new Error( + `${context}: source_agent_type conflicts with source_service_id`, + ); + } + const sourceAgentType = + storedSourceAgentType ?? inferredSourceAgentType ?? null; + const storedTargetAgentType = normalizeStoredAgentType( + input.target_agent_type, + ); + const inferredTargetAgentType = inferAgentTypeFromServiceShadow( + input.target_service_id, + ); + if ( + storedTargetAgentType && + inferredTargetAgentType && + storedTargetAgentType !== inferredTargetAgentType + ) { + throw new Error( + `${context}: target_agent_type conflicts with target_service_id`, + ); + } + const targetAgentType = storedTargetAgentType ?? inferredTargetAgentType; + + if (!targetAgentType) { + throw new Error( + `${context}: cannot resolve target_agent_type from stored row metadata`, + ); + } + + const targetServiceId = + input.target_service_id ?? + (targetRole != null + ? resolveRoleServiceShadow(targetRole, targetAgentType) + : null); + if (!targetServiceId) { + throw new Error( + `${context}: cannot resolve target_service_id from stored row metadata`, + ); + } + + const sourceServiceId = + input.source_service_id ?? + (sourceRole != null && sourceAgentType != null + ? resolveRoleServiceShadow(sourceRole, sourceAgentType) + : null) ?? + SERVICE_SESSION_SCOPE; + + assertStoredRoleMatchesServiceShadow({ + context, + storedRole: sourceRole, + agentType: sourceAgentType, + serviceId: sourceServiceId, + roleField: 'source_role', + serviceField: 'source_service_id', + }); + assertStoredRoleMatchesServiceShadow({ + context, + storedRole: targetRole, + agentType: targetAgentType, + serviceId: targetServiceId, + roleField: 'target_role', + serviceField: 'target_service_id', + }); + + return { + sourceRole, + targetRole, + sourceAgentType, + targetAgentType, + sourceServiceId, + targetServiceId, + }; +} + +interface ExecutionLeaseMetadataRow { + rowid: number; + role: 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; +} + +export function inferExecutionLeaseServiceIdFromCanonicalMetadata( + row: ExecutionLeaseMetadataRow, +): string | null { + switch (row.role) { + case 'owner': { + const owner = resolveOptionalRoleMetadata({ + context: `paired_task_execution_leases(${row.rowid})`, + role: 'owner', + storedAgentType: row.owner_agent_type, + storedServiceId: row.owner_service_id, + }); + return owner.serviceId; + } + case 'reviewer': { + const reviewer = resolveOptionalRoleMetadata({ + context: `paired_task_execution_leases(${row.rowid})`, + role: 'reviewer', + storedAgentType: row.reviewer_agent_type, + storedServiceId: row.reviewer_service_id, + }); + return reviewer.serviceId; + } + case 'arbiter': { + const arbiter = resolveOptionalRoleMetadata({ + context: `paired_task_execution_leases(${row.rowid})`, + role: 'arbiter', + storedAgentType: row.arbiter_agent_type, + storedServiceId: row.arbiter_service_id, + }); + return arbiter.serviceId; + } + default: + return null; + } +} diff --git a/src/db/channel-owner-leases.ts b/src/db/channel-owner-leases.ts index 256cebd..0991fd5 100644 --- a/src/db/channel-owner-leases.ts +++ b/src/db/channel-owner-leases.ts @@ -1,17 +1,7 @@ import { Database } from 'bun:sqlite'; -import { - ARBITER_AGENT_TYPE, - CLAUDE_SERVICE_ID, - OWNER_AGENT_TYPE, -} from '../config.js'; -import { - inferAgentTypeFromServiceShadow, - resolveRoleServiceShadow, -} from '../role-service-shadow.js'; import { AgentType } from '../types.js'; -import { resolveStableReviewerAgentType } from './legacy-rebuilds.js'; -import { normalizeStoredAgentType } from './room-registration.js'; +import { canonicalizeChannelOwnerLeaseMetadata } from './canonical-role-metadata.js'; export interface ChannelOwnerLeaseRow { chat_jid: string; @@ -52,55 +42,28 @@ 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 = - 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 { + ownerAgentType, + reviewerAgentType, + arbiterAgentType, + ownerServiceId, + reviewerServiceId, + arbiterServiceId, + } = canonicalizeChannelOwnerLeaseMetadata({ + chat_jid: row.chat_jid, + owner_service_id: row.owner_service_id, + reviewer_service_id: row.reviewer_service_id, + arbiter_service_id: row.arbiter_service_id, + owner_agent_type: row.owner_agent_type, + reviewer_agent_type: row.reviewer_agent_type, + arbiter_agent_type: row.arbiter_agent_type, + }); return { chat_jid: row.chat_jid, - owner_service_id: - row.owner_service_id ?? - resolveRoleServiceShadow('owner', ownerAgentType) ?? - CLAUDE_SERVICE_ID, - reviewer_service_id: - row.reviewer_service_id ?? - (reviewerAgentType == null - ? null - : resolveRoleServiceShadow('reviewer', reviewerAgentType)), - arbiter_service_id: - row.arbiter_service_id ?? - (arbiterAgentType == null - ? null - : resolveRoleServiceShadow('arbiter', arbiterAgentType)), + owner_service_id: ownerServiceId, + reviewer_service_id: reviewerServiceId, + arbiter_service_id: arbiterServiceId, owner_agent_type: ownerAgentType, reviewer_agent_type: reviewerAgentType, arbiter_agent_type: arbiterAgentType, @@ -132,41 +95,22 @@ export function setChannelOwnerLeaseInDatabase( database: Database, input: SetChannelOwnerLeaseInput, ): void { - const ownerAgentType = - normalizeStoredAgentType(input.owner_agent_type) ?? - inferAgentTypeFromServiceShadow(input.owner_service_id) ?? - OWNER_AGENT_TYPE; - const reviewerAgentType = - input.reviewer_service_id == null && input.reviewer_agent_type == null - ? null - : (normalizeStoredAgentType(input.reviewer_agent_type) ?? - inferAgentTypeFromServiceShadow(input.reviewer_service_id ?? null) ?? - resolveStableReviewerAgentType(ownerAgentType, null)); - const arbiterAgentType = - input.arbiter_service_id == null && input.arbiter_agent_type == null - ? null - : (normalizeStoredAgentType(input.arbiter_agent_type) ?? - 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))); + const { + ownerAgentType, + reviewerAgentType, + arbiterAgentType, + ownerServiceId, + reviewerServiceId, + arbiterServiceId, + } = canonicalizeChannelOwnerLeaseMetadata({ + chat_jid: input.chat_jid, + owner_service_id: input.owner_service_id, + reviewer_service_id: input.reviewer_service_id, + arbiter_service_id: input.arbiter_service_id, + owner_agent_type: input.owner_agent_type, + reviewer_agent_type: input.reviewer_agent_type, + arbiter_agent_type: input.arbiter_agent_type, + }); database .prepare( diff --git a/src/db/legacy-rebuilds.ts b/src/db/legacy-rebuilds.ts deleted file mode 100644 index b5b4251..0000000 --- a/src/db/legacy-rebuilds.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { Database } from 'bun:sqlite'; - -import { ARBITER_AGENT_TYPE, REVIEWER_AGENT_TYPE } from '../config.js'; -import { - collectRegisteredAgentTypes, - collectRegisteredAgentTypesForFolder, - getStoredRoomSettingsRowFromDatabase, - inferOwnerAgentTypeFromRegisteredAgentTypes, - normalizeStoredAgentType, -} from './room-registration.js'; -import type { AgentType, PairedRoomRole } from '../types.js'; - -interface StablePairedTaskRowInput { - chat_jid: string; - group_folder: string; - owner_agent_type?: string | null; -} - -export function resolveStablePairedTaskOwnerAgentType( - database: Database, - task: StablePairedTaskRowInput, -): AgentType | undefined { - const persistedOwnerAgentType = normalizeStoredAgentType( - task.owner_agent_type, - ); - if (persistedOwnerAgentType) { - return persistedOwnerAgentType; - } - - const stored = getStoredRoomSettingsRowFromDatabase(database, task.chat_jid); - if (stored?.ownerAgentType) { - return stored.ownerAgentType; - } - - const jidAgentTypes = collectRegisteredAgentTypes(database, task.chat_jid); - if (jidAgentTypes.length > 0) { - return inferOwnerAgentTypeFromRegisteredAgentTypes(jidAgentTypes); - } - - const folderAgentTypes = collectRegisteredAgentTypesForFolder( - database, - task.group_folder, - ); - if (folderAgentTypes.length > 0) { - return inferOwnerAgentTypeFromRegisteredAgentTypes(folderAgentTypes); - } - - return undefined; -} - -export function resolveStableReviewerAgentType( - ownerAgentType: AgentType | undefined, - fallbackReviewerAgentType?: string | null, -): AgentType | null { - const persistedReviewerAgentType = normalizeStoredAgentType( - fallbackReviewerAgentType, - ); - if (persistedReviewerAgentType) { - return persistedReviewerAgentType; - } - - if (ownerAgentType) { - return REVIEWER_AGENT_TYPE !== ownerAgentType - ? REVIEWER_AGENT_TYPE - : ownerAgentType; - } - return null; -} - -export function resolveStableRoomRoleAgentType( - database: Database, - input: { - chatJid: string; - groupFolder: string; - role: PairedRoomRole; - }, -): AgentType | null | undefined { - if (input.role === 'owner') { - return resolveStablePairedTaskOwnerAgentType(database, { - chat_jid: input.chatJid, - group_folder: input.groupFolder, - owner_agent_type: null, - }); - } - - if (input.role === 'reviewer') { - const ownerAgentType = resolveStablePairedTaskOwnerAgentType(database, { - chat_jid: input.chatJid, - group_folder: input.groupFolder, - owner_agent_type: null, - }); - return resolveStableReviewerAgentType(ownerAgentType, null); - } - - return ARBITER_AGENT_TYPE ?? null; -} diff --git a/src/db/paired-state.ts b/src/db/paired-state.ts index bbd8dbb..5daaf0c 100644 --- a/src/db/paired-state.ts +++ b/src/db/paired-state.ts @@ -1,21 +1,15 @@ import { Database } from 'bun:sqlite'; +import { SERVICE_ID, normalizeServiceId } from '../config.js'; import { - ARBITER_AGENT_TYPE, - CODEX_MAIN_SERVICE_ID, - CODEX_REVIEW_SERVICE_ID, - SERVICE_ID, - normalizeServiceId, -} from '../config.js'; + buildPairedTurnIdentity, + resolvePairedTurnRole, +} from '../paired-turn-identity.js'; +import { canonicalizePairedTaskMetadata } from './canonical-role-metadata.js'; import { - resolveStablePairedTaskOwnerAgentType, - resolveStableReviewerAgentType, -} from './legacy-rebuilds.js'; -import { normalizeStoredAgentType } from './room-registration.js'; -import { - inferAgentTypeFromServiceShadow, - resolveRoleServiceShadow, -} from '../role-service-shadow.js'; + ensurePairedTurnQueuedInDatabase, + markPairedTurnRunningInDatabase, +} from './paired-turns.js'; import { AgentType, PairedProject, @@ -66,69 +60,31 @@ function computeExecutionLeaseExpiry(now: string): string { ).toISOString(); } -function resolveExecutionLeaseRole( - intentKind: PairedTurnReservationIntentKind, -): PairedRoomRole { - switch (intentKind) { - case 'reviewer-turn': - return 'reviewer'; - case 'arbiter-turn': - return 'arbiter'; - case 'owner-turn': - case 'owner-follow-up': - case 'finalize-owner-turn': - default: - return 'owner'; - } -} - function hydratePairedTaskRow( database: Database, row: StoredPairedTaskRow, ): PairedTask { - 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 ?? - null; + const { + ownerAgentType, + reviewerAgentType, + arbiterAgentType, + ownerServiceId, + reviewerServiceId, + } = canonicalizePairedTaskMetadata({ + id: row.id, + owner_service_id: row.owner_service_id, + reviewer_service_id: row.reviewer_service_id, + owner_agent_type: row.owner_agent_type, + reviewer_agent_type: row.reviewer_agent_type, + arbiter_agent_type: row.arbiter_agent_type, + }); 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, - reviewer_agent_type: reviewerAgentType ?? null, + owner_service_id: ownerServiceId, + reviewer_service_id: reviewerServiceId, + owner_agent_type: ownerAgentType, + reviewer_agent_type: reviewerAgentType, arbiter_agent_type: arbiterAgentType, }; } @@ -176,26 +132,20 @@ 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; + const { + ownerAgentType, + reviewerAgentType, + arbiterAgentType, + ownerServiceId, + reviewerServiceId, + } = canonicalizePairedTaskMetadata({ + id: task.id, + owner_service_id: task.owner_service_id, + reviewer_service_id: task.reviewer_service_id, + owner_agent_type: task.owner_agent_type, + reviewer_agent_type: task.reviewer_agent_type, + arbiter_agent_type: task.arbiter_agent_type, + }); database .prepare( @@ -426,6 +376,12 @@ export function reservePairedTurnReservationInDatabase( }, ): boolean { const now = new Date().toISOString(); + const turnIdentity = buildPairedTurnIdentity({ + taskId: args.taskId, + taskUpdatedAt: args.taskUpdatedAt, + intentKind: args.intentKind, + role: resolvePairedTurnRole(args.intentKind), + }); const result = database .prepare( ` @@ -435,6 +391,10 @@ export function reservePairedTurnReservationInDatabase( task_status, round_trip_count, task_updated_at, + turn_id, + turn_attempt_id, + turn_attempt_no, + turn_role, intent_kind, status, scheduled_run_id, @@ -443,8 +403,32 @@ export function reservePairedTurnReservationInDatabase( updated_at, consumed_at ) - VALUES (?, ?, ?, ?, ?, ?, 'pending', ?, NULL, ?, ?, NULL) - ON CONFLICT(chat_jid, task_id, task_updated_at, intent_kind) DO NOTHING + VALUES (?, ?, ?, ?, ?, ?, NULL, NULL, ?, ?, 'pending', ?, NULL, ?, ?, NULL) + ON CONFLICT(chat_jid, task_id, task_updated_at, intent_kind) DO UPDATE SET + task_status = excluded.task_status, + round_trip_count = excluded.round_trip_count, + turn_id = excluded.turn_id, + turn_attempt_id = NULL, + turn_attempt_no = NULL, + turn_role = excluded.turn_role, + status = 'pending', + scheduled_run_id = excluded.scheduled_run_id, + consumed_run_id = NULL, + updated_at = excluded.updated_at, + consumed_at = NULL + WHERE paired_turn_reservations.status = 'completed' + AND EXISTS ( + SELECT 1 + FROM paired_turn_attempts latest_attempt + WHERE latest_attempt.turn_id = paired_turn_reservations.turn_id + AND latest_attempt.state = 'failed' + AND NOT EXISTS ( + SELECT 1 + FROM paired_turn_attempts newer_attempt + WHERE newer_attempt.turn_id = latest_attempt.turn_id + AND newer_attempt.attempt_no > latest_attempt.attempt_no + ) + ) `, ) .run( @@ -453,13 +437,20 @@ export function reservePairedTurnReservationInDatabase( args.taskStatus, args.roundTripCount, args.taskUpdatedAt, + turnIdentity.turnId, + turnIdentity.role, args.intentKind, args.runId, now, now, ); - return result.changes > 0; + if (result.changes > 0) { + ensurePairedTurnQueuedInDatabase(database, turnIdentity); + return true; + } + + return false; } class PairedTurnReservationClaimError extends Error {} @@ -479,7 +470,12 @@ export function claimPairedTurnReservationInDatabase( const tx = database.transaction(() => { const now = new Date().toISOString(); const expiresAt = computeExecutionLeaseExpiry(now); - const leaseRole = resolveExecutionLeaseRole(args.intentKind); + const turnIdentity = buildPairedTurnIdentity({ + taskId: args.taskId, + taskUpdatedAt: args.taskUpdatedAt, + intentKind: args.intentKind, + role: resolvePairedTurnRole(args.intentKind), + }); const existingLease = database .prepare( ` @@ -501,26 +497,30 @@ export function claimPairedTurnReservationInDatabase( const insertedLease = database .prepare( ` - INSERT INTO paired_task_execution_leases ( - task_id, - chat_jid, - role, - intent_kind, - claimed_run_id, - claimed_service_id, + INSERT INTO paired_task_execution_leases ( + task_id, + chat_jid, + role, + turn_id, + turn_attempt_id, + turn_attempt_no, + intent_kind, + claimed_run_id, + claimed_service_id, task_status, task_updated_at, claimed_at, updated_at, expires_at ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, NULL, NULL, ?, ?, ?, ?, ?, ?, ?, ?) `, ) .run( args.taskId, args.chatJid, - leaseRole, + turnIdentity.role, + turnIdentity.turnId, args.intentKind, args.runId, CURRENT_SERVICE_ID, @@ -543,6 +543,9 @@ export function claimPairedTurnReservationInDatabase( UPDATE paired_task_execution_leases SET chat_jid = ?, role = ?, + turn_id = ?, + turn_attempt_id = NULL, + turn_attempt_no = NULL, intent_kind = ?, claimed_run_id = ?, claimed_service_id = ?, @@ -559,7 +562,8 @@ export function claimPairedTurnReservationInDatabase( ) .run( args.chatJid, - leaseRole, + turnIdentity.role, + turnIdentity.turnId, args.intentKind, args.runId, CURRENT_SERVICE_ID, @@ -579,31 +583,6 @@ export function claimPairedTurnReservationInDatabase( } } - database - .prepare( - ` - UPDATE paired_turn_reservations - SET status = 'completed', - consumed_run_id = ?, - updated_at = ?, - consumed_at = ? - WHERE chat_jid = ? - AND task_id = ? - AND task_updated_at = ? - AND intent_kind = ? - AND status = 'pending' - `, - ) - .run( - args.runId, - now, - now, - args.chatJid, - args.taskId, - args.taskUpdatedAt, - args.intentKind, - ); - const claimedTask = database .prepare( ` @@ -619,6 +598,67 @@ export function claimPairedTurnReservationInDatabase( if (claimedTask.changes === 0) { throw new PairedTurnReservationClaimError(); } + + const currentAttempt = markPairedTurnRunningInDatabase(database, { + turnIdentity, + executorServiceId: CURRENT_SERVICE_ID, + runId: args.runId, + }); + if (!currentAttempt) { + throw new Error( + `paired_turns(${turnIdentity.turnId}) did not materialize a running attempt row`, + ); + } + const turnAttemptNo = currentAttempt.attempt_no; + const turnAttemptId = currentAttempt.attempt_id; + + database + .prepare( + ` + UPDATE paired_task_execution_leases + SET turn_attempt_id = ?, + turn_attempt_no = ? + WHERE task_id = ? + AND claimed_service_id = ? + AND claimed_run_id = ? + `, + ) + .run( + turnAttemptId, + turnAttemptNo, + args.taskId, + CURRENT_SERVICE_ID, + args.runId, + ); + + database + .prepare( + ` + UPDATE paired_turn_reservations + SET status = 'completed', + turn_attempt_id = ?, + turn_attempt_no = ?, + consumed_run_id = ?, + updated_at = ?, + consumed_at = ? + WHERE chat_jid = ? + AND task_id = ? + AND task_updated_at = ? + AND intent_kind = ? + AND status = 'pending' + `, + ) + .run( + turnAttemptId, + turnAttemptNo, + args.runId, + now, + now, + args.chatJid, + args.taskId, + args.taskUpdatedAt, + args.intentKind, + ); }); try { diff --git a/src/db/paired-turn-attempts.ts b/src/db/paired-turn-attempts.ts new file mode 100644 index 0000000..3b24b9d --- /dev/null +++ b/src/db/paired-turn-attempts.ts @@ -0,0 +1,451 @@ +import { Database } from 'bun:sqlite'; + +import { normalizeServiceId } from '../config.js'; +import type { PairedTurnIdentity } from '../paired-turn-identity.js'; +import { inferAgentTypeFromServiceShadow } from '../role-service-shadow.js'; +import type { AgentType, PairedRoomRole } from '../types.js'; + +export type PairedTurnAttemptState = + | 'running' + | 'delegated' + | 'completed' + | 'failed' + | 'cancelled'; + +export interface PairedTurnAttemptRecord { + attempt_id: string; + parent_attempt_id: string | null; + parent_handoff_id: number | null; + continuation_handoff_id: number | null; + turn_id: string; + attempt_no: number; + task_id: string; + task_updated_at: string; + role: PairedRoomRole; + intent_kind: PairedTurnIdentity['intentKind']; + state: PairedTurnAttemptState; + executor_service_id: string | null; + executor_agent_type: AgentType | null; + active_run_id: string | null; + created_at: string; + updated_at: string; + completed_at: string | null; + last_error: string | null; +} + +function resolveExecutorMetadata(args: { + executorServiceId?: string | null; + executorAgentType?: AgentType | null; +}): { + executorServiceId: string | null; + executorAgentType: AgentType | null; +} { + const executorServiceId = args.executorServiceId + ? normalizeServiceId(args.executorServiceId) + : null; + const executorAgentType = + args.executorAgentType ?? + inferAgentTypeFromServiceShadow(executorServiceId) ?? + null; + return { + executorServiceId, + executorAgentType, + }; +} + +export function buildPairedTurnAttemptId( + turnId: string, + attemptNo: number, +): string { + return `${turnId}:attempt:${attemptNo}`; +} + +export function buildPairedTurnAttemptParentId( + turnId: string, + attemptNo: number, +): string | null { + if (attemptNo <= 1) { + return null; + } + return buildPairedTurnAttemptId(turnId, attemptNo - 1); +} + +function getPairedTurnAttemptParentIdFromDatabase( + database: Database, + turnId: string, + attemptNo: number, +): string | null { + if (attemptNo <= 1) { + return null; + } + const parentAttemptId = getPairedTurnAttemptIdFromDatabase( + database, + turnId, + attemptNo - 1, + ); + if (parentAttemptId === null) { + throw new Error( + `paired_turn_attempts(${turnId}, attempt=${attemptNo}) must preserve contiguous parent lineage`, + ); + } + return parentAttemptId; +} + +function tableHasColumn( + database: Database, + tableName: string, + columnName: string, +): boolean { + const rows = database + .prepare(`PRAGMA table_info(${tableName})`) + .all() as Array<{ name: string }>; + return rows.some((row) => row.name === columnName); +} + +export function syncPairedTurnAttemptInDatabase( + database: Database, + args: { + turnIdentity: PairedTurnIdentity; + attemptNo: number; + state: PairedTurnAttemptState; + executorServiceId?: string | null; + executorAgentType?: AgentType | null; + activeRunId?: string | null; + parentHandoffId?: number | null; + continuationHandoffId?: number | null; + now?: string; + error?: string | null; + }, +): void { + if (args.attemptNo < 1) { + return; + } + + const now = args.now ?? new Date().toISOString(); + const terminal = + args.state === 'completed' || + args.state === 'failed' || + args.state === 'cancelled'; + const { executorServiceId, executorAgentType } = resolveExecutorMetadata({ + executorServiceId: args.executorServiceId, + executorAgentType: args.executorAgentType, + }); + const activeRunId = + args.state === 'running' ? (args.activeRunId ?? null) : null; + + database + .prepare( + ` + INSERT INTO paired_turn_attempts ( + attempt_id, + parent_attempt_id, + parent_handoff_id, + continuation_handoff_id, + turn_id, + attempt_no, + task_id, + task_updated_at, + role, + intent_kind, + state, + executor_service_id, + executor_agent_type, + active_run_id, + created_at, + updated_at, + completed_at, + last_error + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(turn_id, attempt_no) DO UPDATE SET + attempt_id = excluded.attempt_id, + parent_attempt_id = excluded.parent_attempt_id, + parent_handoff_id = CASE + WHEN excluded.parent_handoff_id IS NOT NULL + THEN excluded.parent_handoff_id + ELSE paired_turn_attempts.parent_handoff_id + END, + continuation_handoff_id = CASE + WHEN excluded.continuation_handoff_id IS NOT NULL + THEN excluded.continuation_handoff_id + ELSE paired_turn_attempts.continuation_handoff_id + END, + task_id = excluded.task_id, + task_updated_at = excluded.task_updated_at, + role = excluded.role, + intent_kind = excluded.intent_kind, + state = excluded.state, + executor_service_id = COALESCE( + excluded.executor_service_id, + paired_turn_attempts.executor_service_id + ), + executor_agent_type = COALESCE( + excluded.executor_agent_type, + paired_turn_attempts.executor_agent_type + ), + active_run_id = CASE + WHEN excluded.state = 'running' + THEN excluded.active_run_id + ELSE NULL + END, + updated_at = excluded.updated_at, + completed_at = excluded.completed_at, + last_error = excluded.last_error + `, + ) + .run( + buildPairedTurnAttemptId(args.turnIdentity.turnId, args.attemptNo), + getPairedTurnAttemptParentIdFromDatabase( + database, + args.turnIdentity.turnId, + args.attemptNo, + ), + args.parentHandoffId ?? null, + args.continuationHandoffId ?? null, + args.turnIdentity.turnId, + args.attemptNo, + args.turnIdentity.taskId, + args.turnIdentity.taskUpdatedAt, + args.turnIdentity.role, + args.turnIdentity.intentKind, + args.state, + executorServiceId, + executorAgentType, + activeRunId, + now, + now, + terminal ? now : null, + args.error ?? null, + ); +} + +export function backfillPairedTurnAttemptsFromTurns(database: Database): void { + if ( + !tableHasColumn(database, 'paired_turns', 'state') || + !tableHasColumn(database, 'paired_turns', 'attempt_no') + ) { + return; + } + + const rows = database + .prepare( + ` + SELECT * + FROM paired_turns + WHERE attempt_no >= 1 + AND state IN ('running', 'delegated', 'completed', 'failed', 'cancelled') + AND NOT EXISTS ( + SELECT 1 + FROM paired_turn_attempts existing_attempt + WHERE existing_attempt.turn_id = paired_turns.turn_id + AND existing_attempt.attempt_no = paired_turns.attempt_no + ) + `, + ) + .all() as Array<{ + attempt_id: string | null; + parent_attempt_id: string | null; + turn_id: string; + task_id: string; + task_updated_at: string; + role: PairedRoomRole; + intent_kind: PairedTurnIdentity['intentKind']; + state: PairedTurnAttemptState; + executor_service_id: string | null; + executor_agent_type: AgentType | null; + active_run_id: string | null; + attempt_no: number; + created_at: string; + updated_at: string; + completed_at: string | null; + last_error: string | null; + }>; + + if (rows.length === 0) { + return; + } + + const insert = database.prepare( + ` + INSERT INTO paired_turn_attempts ( + attempt_id, + parent_attempt_id, + parent_handoff_id, + continuation_handoff_id, + turn_id, + attempt_no, + task_id, + task_updated_at, + role, + intent_kind, + state, + executor_service_id, + executor_agent_type, + active_run_id, + created_at, + updated_at, + completed_at, + last_error + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(turn_id, attempt_no) DO NOTHING + `, + ); + + const tx = database.transaction( + ( + attemptRows: Array<{ + attempt_id?: string | null; + parent_attempt_id?: string | null; + continuation_handoff_id?: number | null; + turn_id: string; + task_id: string; + task_updated_at: string; + role: PairedRoomRole; + intent_kind: PairedTurnIdentity['intentKind']; + state: PairedTurnAttemptState; + executor_service_id: string | null; + executor_agent_type: AgentType | null; + active_run_id: string | null; + attempt_no: number; + created_at: string; + updated_at: string; + completed_at: string | null; + last_error: string | null; + }>, + ) => { + for (const row of attemptRows) { + const { executorServiceId, executorAgentType } = + resolveExecutorMetadata({ + executorServiceId: row.executor_service_id, + executorAgentType: row.executor_agent_type, + }); + insert.run( + row.attempt_id ?? + buildPairedTurnAttemptId(row.turn_id, row.attempt_no), + row.parent_attempt_id ?? + getPairedTurnAttemptParentIdFromDatabase( + database, + row.turn_id, + row.attempt_no, + ), + null, + row.continuation_handoff_id ?? null, + row.turn_id, + row.attempt_no, + row.task_id, + row.task_updated_at, + row.role, + row.intent_kind, + row.state, + executorServiceId, + executorAgentType, + row.state === 'running' ? (row.active_run_id ?? null) : null, + row.created_at, + row.updated_at, + row.completed_at, + row.last_error, + ); + } + }, + ); + + tx(rows); +} + +export function getPairedTurnAttemptsForTurnFromDatabase( + database: Database, + turnId: string, +): PairedTurnAttemptRecord[] { + return database + .prepare( + ` + SELECT * + FROM paired_turn_attempts + WHERE turn_id = ? + ORDER BY attempt_no ASC + `, + ) + .all(turnId) as PairedTurnAttemptRecord[]; +} + +export function getCurrentPairedTurnAttemptForTurnFromDatabase( + database: Database, + turnId: string, +): PairedTurnAttemptRecord | undefined { + return database + .prepare( + ` + SELECT * + FROM paired_turn_attempts + WHERE turn_id = ? + ORDER BY attempt_no DESC + LIMIT 1 + `, + ) + .get(turnId) as PairedTurnAttemptRecord | undefined; +} + +export function getPairedTurnAttemptByNumberFromDatabase( + database: Database, + turnId: string, + attemptNo: number, +): PairedTurnAttemptRecord | undefined { + return database + .prepare( + ` + SELECT * + FROM paired_turn_attempts + WHERE turn_id = ? + AND attempt_no = ? + `, + ) + .get(turnId, attemptNo) as PairedTurnAttemptRecord | undefined; +} + +export function getPairedTurnAttemptIdFromDatabase( + database: Database, + turnId: string, + attemptNo: number, +): string | null { + const row = database + .prepare( + ` + SELECT COALESCE(attempt_id, ? || ':attempt:' || CAST(? AS TEXT)) AS attempt_id + FROM paired_turn_attempts + WHERE turn_id = ? + AND attempt_no = ? + `, + ) + .get(turnId, attemptNo, turnId, attemptNo) as + | { attempt_id: string } + | undefined; + return row?.attempt_id ?? null; +} + +export function setPairedTurnAttemptContinuationHandoffIdInDatabase( + database: Database, + args: { + turnId: string; + attemptNo: number; + handoffId: number | null; + }, +): void { + if (args.attemptNo < 1) { + return; + } + database + .prepare( + ` + UPDATE paired_turn_attempts + SET continuation_handoff_id = ? + WHERE turn_id = ? + AND attempt_no = ? + `, + ) + .run(args.handoffId, args.turnId, args.attemptNo); +} + +export function clearPairedTurnAttemptsInDatabase(database: Database): void { + database.prepare('DELETE FROM paired_turn_attempts').run(); +} diff --git a/src/db/paired-turns.ts b/src/db/paired-turns.ts new file mode 100644 index 0000000..8636c74 --- /dev/null +++ b/src/db/paired-turns.ts @@ -0,0 +1,515 @@ +import { Database } from 'bun:sqlite'; + +import { normalizeServiceId } from '../config.js'; +import type { PairedTurnIdentity } from '../paired-turn-identity.js'; +import { inferAgentTypeFromServiceShadow } from '../role-service-shadow.js'; +import type { AgentType, PairedRoomRole } from '../types.js'; +import { + getPairedTurnAttemptByNumberFromDatabase, + getCurrentPairedTurnAttemptForTurnFromDatabase, + type PairedTurnAttemptRecord, + syncPairedTurnAttemptInDatabase, +} from './paired-turn-attempts.js'; + +export type PairedTurnState = + | 'queued' + | 'running' + | 'delegated' + | 'completed' + | 'failed' + | 'cancelled'; + +export interface PairedTurnRecord { + turn_id: string; + task_id: string; + task_updated_at: string; + role: PairedRoomRole; + intent_kind: PairedTurnIdentity['intentKind']; + state: PairedTurnState; + executor_service_id: string | null; + executor_agent_type: AgentType | null; + attempt_no: number; + created_at: string; + updated_at: string; + completed_at: string | null; + last_error: string | null; +} + +interface StoredPairedTurnRow { + turn_id: string; + task_id: string; + task_updated_at: string; + role: PairedRoomRole; + intent_kind: PairedTurnIdentity['intentKind']; + created_at: string; + updated_at: string; +} + +function hydratePairedTurnRecord( + row: StoredPairedTurnRow, + currentAttemptRow?: PairedTurnAttemptRecord, +): PairedTurnRecord { + if (!currentAttemptRow) { + return { + ...row, + state: 'queued', + executor_service_id: null, + executor_agent_type: null, + attempt_no: 0, + completed_at: null, + last_error: null, + }; + } + + return { + ...row, + task_id: currentAttemptRow.task_id, + task_updated_at: currentAttemptRow.task_updated_at, + role: currentAttemptRow.role, + intent_kind: currentAttemptRow.intent_kind, + state: currentAttemptRow.state, + executor_service_id: currentAttemptRow.executor_service_id, + executor_agent_type: currentAttemptRow.executor_agent_type, + attempt_no: currentAttemptRow.attempt_no, + updated_at: currentAttemptRow.updated_at, + completed_at: currentAttemptRow.completed_at, + last_error: currentAttemptRow.last_error, + }; +} + +function resolveExecutorMetadata(args: { + executorServiceId?: string | null; + executorAgentType?: AgentType | null; +}): { + executorServiceId: string | null; + executorAgentType: AgentType | null; +} { + const executorServiceId = args.executorServiceId + ? normalizeServiceId(args.executorServiceId) + : null; + const executorAgentType = + args.executorAgentType ?? + inferAgentTypeFromServiceShadow(executorServiceId) ?? + null; + return { + executorServiceId, + executorAgentType, + }; +} + +function getRequiredPairedTurnAttemptByNumber( + database: Database, + turnIdentity: PairedTurnIdentity, + attemptNo: number, +): PairedTurnAttemptRecord { + const currentAttemptRow = getPairedTurnAttemptByNumberFromDatabase( + database, + turnIdentity.turnId, + attemptNo, + ); + if (!currentAttemptRow) { + throw new Error( + `paired_turns(${turnIdentity.turnId}) did not materialize attempt ${attemptNo}`, + ); + } + return currentAttemptRow; +} + +export function ensurePairedTurnQueuedInDatabase( + database: Database, + turnIdentity: PairedTurnIdentity, +): void { + const now = new Date().toISOString(); + database + .prepare( + ` + INSERT INTO paired_turns ( + turn_id, + task_id, + task_updated_at, + role, + intent_kind, + created_at, + updated_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(turn_id) DO UPDATE SET + task_id = excluded.task_id, + task_updated_at = excluded.task_updated_at, + role = excluded.role, + intent_kind = excluded.intent_kind, + updated_at = excluded.updated_at, + created_at = paired_turns.created_at + `, + ) + .run( + turnIdentity.turnId, + turnIdentity.taskId, + turnIdentity.taskUpdatedAt, + turnIdentity.role, + turnIdentity.intentKind, + now, + now, + ); +} + +export function markPairedTurnRunningInDatabase( + database: Database, + args: { + turnIdentity: PairedTurnIdentity; + executorServiceId?: string | null; + executorAgentType?: AgentType | null; + runId?: string | null; + }, +): PairedTurnAttemptRecord | undefined { + const now = new Date().toISOString(); + const { executorServiceId, executorAgentType } = resolveExecutorMetadata({ + executorServiceId: args.executorServiceId, + executorAgentType: args.executorAgentType, + }); + const runId = args.runId ?? null; + return database.transaction(() => { + const previousTurnRow = getPairedTurnByIdFromDatabase( + database, + args.turnIdentity.turnId, + ); + const previousAttemptRow = previousTurnRow + ? getCurrentPairedTurnAttemptForTurnFromDatabase( + database, + args.turnIdentity.turnId, + ) + : undefined; + const previousAttemptNo = previousAttemptRow?.attempt_no ?? 0; + const previousAttemptActiveRunId = + previousAttemptRow?.active_run_id ?? null; + const isSameRunContinuation = + previousAttemptRow?.state === 'running' && + previousAttemptActiveRunId !== null && + runId !== null && + previousAttemptActiveRunId === runId; + const isDelegatedContinuation = + previousAttemptRow?.state === 'delegated' && + (previousAttemptRow.executor_service_id ?? '') === + (executorServiceId ?? '') && + (previousAttemptRow.executor_agent_type ?? '') === + (executorAgentType ?? ''); + const nextAttemptNo = previousAttemptRow + ? isSameRunContinuation || isDelegatedContinuation + ? Math.max(previousAttemptNo, 1) + : Math.max(previousAttemptNo, 0) + 1 + : 1; + const nextAttemptParentHandoffId = + previousAttemptRow && !isSameRunContinuation && !isDelegatedContinuation + ? previousAttemptRow.continuation_handoff_id + : null; + + if ( + previousTurnRow && + previousTurnRow.attempt_no >= 1 && + !previousAttemptRow + ) { + throw new Error( + `paired_turns(${args.turnIdentity.turnId}) cannot derive retry lineage because attempt ${previousTurnRow.attempt_no} is missing`, + ); + } + + database + .prepare( + ` + INSERT INTO paired_turns ( + turn_id, + task_id, + task_updated_at, + role, + intent_kind, + created_at, + updated_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(turn_id) DO UPDATE SET + task_id = excluded.task_id, + task_updated_at = excluded.task_updated_at, + role = excluded.role, + intent_kind = excluded.intent_kind, + updated_at = excluded.updated_at, + created_at = paired_turns.created_at + `, + ) + .run( + args.turnIdentity.turnId, + args.turnIdentity.taskId, + args.turnIdentity.taskUpdatedAt, + args.turnIdentity.role, + args.turnIdentity.intentKind, + now, + now, + ); + + if ( + previousAttemptRow?.state === 'running' && + previousAttemptNo >= 1 && + previousAttemptActiveRunId && + runId && + previousAttemptActiveRunId !== runId + ) { + syncPairedTurnAttemptInDatabase(database, { + turnIdentity: args.turnIdentity, + attemptNo: previousAttemptNo, + state: 'cancelled', + executorServiceId: previousAttemptRow.executor_service_id, + executorAgentType: previousAttemptRow.executor_agent_type, + now, + }); + } + + syncPairedTurnAttemptInDatabase(database, { + turnIdentity: args.turnIdentity, + attemptNo: nextAttemptNo, + state: 'running', + executorServiceId, + executorAgentType, + activeRunId: runId, + parentHandoffId: nextAttemptParentHandoffId, + }); + + return getRequiredPairedTurnAttemptByNumber( + database, + args.turnIdentity, + nextAttemptNo, + ); + })(); +} + +export function markPairedTurnDelegatedInDatabase( + database: Database, + args: { + turnIdentity: PairedTurnIdentity; + executorServiceId?: string | null; + executorAgentType?: AgentType | null; + }, +): PairedTurnAttemptRecord | undefined { + const now = new Date().toISOString(); + const { executorServiceId, executorAgentType } = resolveExecutorMetadata({ + executorServiceId: args.executorServiceId, + executorAgentType: args.executorAgentType, + }); + return database.transaction(() => { + const previousTurnRow = getPairedTurnByIdFromDatabase( + database, + args.turnIdentity.turnId, + ); + const currentAttemptRow = previousTurnRow + ? getCurrentPairedTurnAttemptForTurnFromDatabase( + database, + args.turnIdentity.turnId, + ) + : undefined; + if ( + previousTurnRow && + previousTurnRow.attempt_no >= 1 && + !currentAttemptRow + ) { + throw new Error( + `paired_turns(${args.turnIdentity.turnId}) cannot mark delegated because attempt ${previousTurnRow.attempt_no} is missing`, + ); + } + const currentAttemptNo = Math.max(currentAttemptRow?.attempt_no ?? 1, 1); + + database + .prepare( + ` + INSERT INTO paired_turns ( + turn_id, + task_id, + task_updated_at, + role, + intent_kind, + created_at, + updated_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(turn_id) DO UPDATE SET + task_id = excluded.task_id, + task_updated_at = excluded.task_updated_at, + role = excluded.role, + intent_kind = excluded.intent_kind, + updated_at = excluded.updated_at, + created_at = paired_turns.created_at + `, + ) + .run( + args.turnIdentity.turnId, + args.turnIdentity.taskId, + args.turnIdentity.taskUpdatedAt, + args.turnIdentity.role, + args.turnIdentity.intentKind, + now, + now, + ); + + syncPairedTurnAttemptInDatabase(database, { + turnIdentity: args.turnIdentity, + attemptNo: currentAttemptNo, + state: 'delegated', + executorServiceId, + executorAgentType, + now, + }); + + return getRequiredPairedTurnAttemptByNumber( + database, + args.turnIdentity, + currentAttemptNo, + ); + })(); +} + +function markPairedTurnTerminalStateInDatabase( + database: Database, + args: { + turnIdentity: PairedTurnIdentity; + state: 'completed' | 'failed' | 'cancelled'; + error?: string | null; + }, +): void { + const now = new Date().toISOString(); + database.transaction(() => { + const existingTurnRow = getPairedTurnByIdFromDatabase( + database, + args.turnIdentity.turnId, + ); + const currentAttemptRow = existingTurnRow + ? getCurrentPairedTurnAttemptForTurnFromDatabase( + database, + args.turnIdentity.turnId, + ) + : undefined; + if ( + existingTurnRow && + existingTurnRow.attempt_no >= 1 && + !currentAttemptRow + ) { + throw new Error( + `paired_turns(${args.turnIdentity.turnId}) cannot mark ${args.state} because attempt ${existingTurnRow.attempt_no} is missing`, + ); + } + const currentAttemptNo = Math.max(currentAttemptRow?.attempt_no ?? 1, 1); + + database + .prepare( + ` + INSERT INTO paired_turns ( + turn_id, + task_id, + task_updated_at, + role, + intent_kind, + created_at, + updated_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(turn_id) DO UPDATE SET + task_id = excluded.task_id, + task_updated_at = excluded.task_updated_at, + role = excluded.role, + intent_kind = excluded.intent_kind, + updated_at = excluded.updated_at, + created_at = paired_turns.created_at + `, + ) + .run( + args.turnIdentity.turnId, + args.turnIdentity.taskId, + args.turnIdentity.taskUpdatedAt, + args.turnIdentity.role, + args.turnIdentity.intentKind, + now, + now, + ); + + syncPairedTurnAttemptInDatabase(database, { + turnIdentity: args.turnIdentity, + attemptNo: currentAttemptNo, + state: args.state, + executorServiceId: currentAttemptRow?.executor_service_id, + executorAgentType: currentAttemptRow?.executor_agent_type, + now, + error: args.error, + }); + })(); +} + +export function completePairedTurnInDatabase( + database: Database, + turnIdentity: PairedTurnIdentity, +): void { + markPairedTurnTerminalStateInDatabase(database, { + turnIdentity, + state: 'completed', + }); +} + +export function failPairedTurnInDatabase( + database: Database, + args: { + turnIdentity: PairedTurnIdentity; + error?: string | null; + }, +): void { + markPairedTurnTerminalStateInDatabase(database, { + turnIdentity: args.turnIdentity, + state: 'failed', + error: args.error, + }); +} + +export function getPairedTurnByIdFromDatabase( + database: Database, + turnId: string, +): PairedTurnRecord | undefined { + const row = database + .prepare('SELECT * FROM paired_turns WHERE turn_id = ?') + .get(turnId) as StoredPairedTurnRow | undefined; + if (!row) { + return undefined; + } + return hydratePairedTurnRecord( + row, + getCurrentPairedTurnAttemptForTurnFromDatabase(database, turnId), + ); +} + +export function getPairedTurnsForTaskFromDatabase( + database: Database, + taskId: string, +): PairedTurnRecord[] { + const rows = database + .prepare( + ` + SELECT * + FROM paired_turns + WHERE COALESCE( + ( + SELECT paired_turn_attempts.task_id + FROM paired_turn_attempts + WHERE paired_turn_attempts.turn_id = paired_turns.turn_id + ORDER BY paired_turn_attempts.attempt_no DESC + LIMIT 1 + ), + paired_turns.task_id + ) = ? + ORDER BY created_at ASC, turn_id ASC + `, + ) + .all(taskId) as StoredPairedTurnRow[]; + return rows.map((row) => + hydratePairedTurnRecord( + row, + getCurrentPairedTurnAttemptForTurnFromDatabase(database, row.turn_id), + ), + ); +} + +export function clearPairedTurnsInDatabase(database: Database): void { + database.prepare('DELETE FROM paired_turns').run(); +} diff --git a/src/db/room-registration.ts b/src/db/room-registration.ts index 342ac08..689bdb1 100644 --- a/src/db/room-registration.ts +++ b/src/db/room-registration.ts @@ -47,6 +47,31 @@ export interface RegisteredGroupDatabaseRow { work_dir: string | null; } +export interface RoomRoleOverrideSnapshot { + role: 'owner' | 'reviewer' | 'arbiter'; + agentType: AgentType; + agentConfig?: RegisteredGroup['agentConfig']; + createdAt: string; + updatedAt: string; +} + +interface StoredRoomRoleOverrideRow { + role: 'owner' | 'reviewer' | 'arbiter'; + agentType: AgentType; + agentConfig?: RegisteredGroup['agentConfig']; + createdAt: string; + updatedAt: string; +} + +export interface LegacyRoomMigrationPlan { + chatJid: string; + roomMode: RoomMode; + createdAt: string; + updatedAt: string; + snapshot: RoomRegistrationSnapshot; + roleOverrides: RoomRoleOverrideSnapshot[]; +} + export function normalizeRoomModeSource( source: string | null | undefined, ): RoomModeSource | undefined { @@ -196,7 +221,132 @@ export function getLegacyRegisteredGroup( return parseRegisteredGroupRow(row); } -export function getRegisteredGroupCapabilityMetadata( +export function getPendingLegacyRegisteredGroupJids( + database: Database, +): string[] { + const rows = database + .prepare( + `SELECT DISTINCT jid + FROM registered_groups + ORDER BY jid`, + ) + .all() as Array<{ jid: string }>; + + return rows + .map((row) => row.jid) + .filter((jid) => jidRequiresLegacyRoomMigration(database, jid)); +} + +export function countPendingLegacyRegisteredGroupRows( + database: Database, +): number { + let count = 0; + const countRows = database.prepare( + `SELECT COUNT(*) AS count + FROM registered_groups + WHERE jid = ?`, + ); + + for (const jid of getPendingLegacyRegisteredGroupJids(database)) { + const row = countRows.get(jid) as { count: number }; + count += row.count; + } + + return count; +} + +export function deleteLegacyRegisteredGroupRowsForJid( + database: Database, + jid: string, +): void { + database + .prepare( + `DELETE FROM registered_groups + WHERE jid = ?`, + ) + .run(jid); +} + +function getStoredRoomRoleOverrideRows( + database: Database, + jid: string, +): Map<'owner' | 'reviewer' | 'arbiter', StoredRoomRoleOverrideRow> { + let rows: Array<{ + role: 'owner' | 'reviewer' | 'arbiter'; + agent_type: string | null; + agent_config_json: string | null; + created_at: string; + updated_at: string; + }> = []; + try { + rows = database + .prepare( + `SELECT role, agent_type, agent_config_json, created_at, updated_at + FROM room_role_overrides + WHERE chat_jid = ?`, + ) + .all(jid) as Array<{ + role: 'owner' | 'reviewer' | 'arbiter'; + agent_type: string | null; + agent_config_json: string | null; + created_at: string; + updated_at: string; + }>; + } catch { + return new Map(); + } + + const result = new Map< + 'owner' | 'reviewer' | 'arbiter', + StoredRoomRoleOverrideRow + >(); + for (const row of rows) { + const agentType = normalizeStoredAgentType(row.agent_type); + if (!agentType) continue; + result.set(row.role, { + role: row.role, + agentType, + agentConfig: row.agent_config_json + ? JSON.parse(row.agent_config_json) + : undefined, + createdAt: row.created_at, + updatedAt: row.updated_at, + }); + } + return result; +} + +function normalizeAgentConfigValue( + agentConfig: RegisteredGroup['agentConfig'] | undefined, +): string { + return JSON.stringify(agentConfig ?? null); +} + +function roomRoleOverrideMatches( + actual: StoredRoomRoleOverrideRow | undefined, + expected: RoomRoleOverrideSnapshot, +): boolean { + return ( + actual?.agentType === expected.agentType && + normalizeAgentConfigValue(actual.agentConfig) === + normalizeAgentConfigValue(expected.agentConfig) + ); +} + +function storedRoomSettingsMatchesLegacySnapshot( + stored: StoredRoomSettings, + snapshot: RoomRegistrationSnapshot, +): boolean { + return ( + stored.name === snapshot.name && + stored.folder === snapshot.folder && + (stored.requiresTrigger ?? true) === snapshot.requiresTrigger && + (stored.isMain ?? false) === snapshot.isMain && + (stored.workDir ?? null) === (snapshot.workDir ?? null) + ); +} + +function getRegisteredGroupCapabilityMetadata( database: Database, jid: string, preferredAgentType?: AgentType, @@ -229,6 +379,130 @@ export function getRegisteredGroupCapabilityMetadata( }; } +function resolveReviewerAgentTypeFromRegisteredAgentTypes( + agentTypes: readonly AgentType[], + ownerAgentType: AgentType, +): AgentType | undefined { + return agentTypes.find((agentType) => agentType !== ownerAgentType); +} + +export function buildLegacyRoomMigrationPlan( + database: Database, + jid: string, +): LegacyRoomMigrationPlan | undefined { + const existingStored = getStoredRoomSettingsRowFromDatabase(database, jid); + const snapshot = collectRoomRegistrationSnapshot( + database, + jid, + existingStored, + ); + if (!snapshot) return undefined; + + const rows = database + .prepare( + `SELECT added_at + FROM registered_groups + WHERE jid = ? + ORDER BY added_at, agent_type`, + ) + .all(jid) as Array<{ added_at: string }>; + if (rows.length === 0) return undefined; + + const agentTypes = collectRegisteredAgentTypes(database, jid); + const roomMode = + existingStored?.roomMode ?? + inferRoomModeFromRegisteredAgentTypes(agentTypes); + const createdAt = rows[0].added_at; + const updatedAt = rows[rows.length - 1].added_at; + const roleOverrides: RoomRoleOverrideSnapshot[] = []; + + const ownerMetadata = getRegisteredGroupCapabilityMetadata( + database, + jid, + snapshot.ownerAgentType, + ); + roleOverrides.push({ + role: 'owner', + agentType: snapshot.ownerAgentType, + agentConfig: ownerMetadata?.agentConfig, + createdAt: ownerMetadata?.added_at ?? createdAt, + updatedAt, + }); + + if (roomMode === 'tribunal') { + const reviewerAgentType = resolveReviewerAgentTypeFromRegisteredAgentTypes( + agentTypes, + snapshot.ownerAgentType, + ); + if (!reviewerAgentType) { + throw new Error( + `Missing reviewer agent type for tribunal legacy room ${jid}`, + ); + } + const reviewerMetadata = getRegisteredGroupCapabilityMetadata( + database, + jid, + reviewerAgentType, + ); + roleOverrides.push({ + role: 'reviewer', + agentType: reviewerAgentType, + agentConfig: reviewerMetadata?.agentConfig, + createdAt: reviewerMetadata?.added_at ?? createdAt, + updatedAt, + }); + } + + return { + chatJid: jid, + roomMode, + createdAt, + updatedAt, + snapshot, + roleOverrides, + }; +} + +function jidRequiresLegacyRoomMigration( + database: Database, + jid: string, +): boolean { + let stored: StoredRoomSettings | undefined; + try { + stored = getStoredRoomSettingsRowFromDatabase(database, jid); + } catch { + return true; + } + if (!stored) { + return true; + } + + let plan: LegacyRoomMigrationPlan | undefined; + try { + plan = buildLegacyRoomMigrationPlan(database, jid); + } catch { + return true; + } + if (!plan) { + return false; + } + + if (!storedRoomSettingsMatchesLegacySnapshot(stored, plan.snapshot)) { + return true; + } + + const existingOverrides = getStoredRoomRoleOverrideRows(database, jid); + for (const override of plan.roleOverrides) { + if ( + !roomRoleOverrideMatches(existingOverrides.get(override.role), override) + ) { + return true; + } + } + + return false; +} + export function getStoredRoomSettingsRowFromDatabase( database: Database, chatJid: string, @@ -300,14 +574,16 @@ export function buildRegisteredGroupFromStoredSettings( requestedAgentType?: AgentType, ): (RegisteredGroup & { jid: string }) | undefined { const capabilityTypes = resolveStoredRoomCapabilityTypes(database, stored); + const ownerAgentType = + stored.ownerAgentType || + (capabilityTypes.length > 0 + ? inferOwnerAgentTypeFromRegisteredAgentTypes(capabilityTypes) + : undefined); const resolvedAgentType = requestedAgentType ? capabilityTypes.includes(requestedAgentType) ? requestedAgentType : undefined - : stored.ownerAgentType || - (capabilityTypes.length > 0 - ? inferOwnerAgentTypeFromRegisteredAgentTypes(capabilityTypes) - : undefined); + : ownerAgentType; if (!resolvedAgentType) return undefined; if (!stored.folder || !isValidGroupFolder(stored.folder)) { @@ -318,18 +594,19 @@ export function buildRegisteredGroupFromStoredSettings( return undefined; } - const capabilityMetadata = getRegisteredGroupCapabilityMetadata( + const role: 'owner' | 'reviewer' = + ownerAgentType === resolvedAgentType ? 'owner' : 'reviewer'; + const capabilityMetadata = getStoredRoomRoleOverrideRows( database, stored.chatJid, - resolvedAgentType, - ); + ).get(role); return { jid: stored.chatJid, name: stored.name || stored.chatJid, folder: stored.folder, trigger: stored.trigger || `@${ASSISTANT_NAME}`, - added_at: capabilityMetadata?.added_at || new Date(0).toISOString(), + added_at: capabilityMetadata?.createdAt || new Date(0).toISOString(), agentConfig: capabilityMetadata?.agentConfig, requiresTrigger: stored.requiresTrigger, isMain: stored.isMain ? true : undefined, @@ -338,23 +615,43 @@ export function buildRegisteredGroupFromStoredSettings( }; } +export function inferStoredRoomCapabilityTypes( + database: Database, + stored: StoredRoomSettings, +): AgentType[] { + const overrides = getStoredRoomRoleOverrideRows(database, stored.chatJid); + const inferredTypes = new Set(); + for (const override of overrides.values()) { + inferredTypes.add(override.agentType); + } + if (inferredTypes.size === 0 && stored.ownerAgentType) { + inferredTypes.add(stored.ownerAgentType); + } + return [...inferredTypes]; +} + export function resolveStoredRoomCapabilityTypes( database: Database, stored: StoredRoomSettings, ): AgentType[] { - const projectedTypes = collectRegisteredAgentTypes(database, stored.chatJid); + const overrides = getStoredRoomRoleOverrideRows(database, stored.chatJid); + const ownerAgentType = + stored.ownerAgentType ?? + overrides.get('owner')?.agentType ?? + OWNER_AGENT_TYPE; + if (stored.modeSource !== 'explicit') { - return projectedTypes; + return inferStoredRoomCapabilityTypes(database, stored); } - const ownerAgentType = - stored.ownerAgentType || - (projectedTypes.length > 0 - ? inferOwnerAgentTypeFromRegisteredAgentTypes(projectedTypes) - : OWNER_AGENT_TYPE); const types = new Set([ownerAgentType]); if (stored.roomMode === 'tribunal') { - types.add(REVIEWER_AGENT_TYPE); + const reviewerAgentType = + overrides.get('reviewer')?.agentType ?? + (ownerAgentType === REVIEWER_AGENT_TYPE + ? OWNER_AGENT_TYPE + : REVIEWER_AGENT_TYPE); + types.add(reviewerAgentType); } return [...types]; } @@ -659,6 +956,7 @@ export function insertStoredRoomSettings( source: RoomModeSource, snapshot: RoomRegistrationSnapshot, ): void { + const now = new Date().toISOString(); database .prepare( `INSERT INTO room_settings ( @@ -672,8 +970,9 @@ export function insertStoredRoomSettings( is_main, owner_agent_type, work_dir, + created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) .run( chatJid, @@ -686,10 +985,150 @@ export function insertStoredRoomSettings( snapshot.isMain ? 1 : 0, snapshot.ownerAgentType, snapshot.workDir, - new Date().toISOString(), + now, + now, ); } +export function insertStoredRoomSettingsFromMigration( + database: Database, + plan: LegacyRoomMigrationPlan, +): void { + database + .prepare( + `INSERT INTO room_settings ( + chat_jid, + room_mode, + mode_source, + name, + folder, + trigger_pattern, + requires_trigger, + is_main, + owner_agent_type, + work_dir, + created_at, + updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(chat_jid) DO NOTHING`, + ) + .run( + plan.chatJid, + plan.roomMode, + 'inferred', + plan.snapshot.name, + plan.snapshot.folder, + plan.snapshot.triggerPattern, + plan.snapshot.requiresTrigger ? 1 : 0, + plan.snapshot.isMain ? 1 : 0, + plan.snapshot.ownerAgentType, + plan.snapshot.workDir, + plan.createdAt, + plan.updatedAt, + ); +} + +export function upsertRoomRoleOverride( + database: Database, + chatJid: string, + override: RoomRoleOverrideSnapshot, +): void { + database + .prepare( + `INSERT INTO room_role_overrides ( + chat_jid, + role, + agent_type, + agent_config_json, + created_at, + updated_at + ) VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(chat_jid, role) DO UPDATE SET + agent_type = excluded.agent_type, + agent_config_json = excluded.agent_config_json, + updated_at = excluded.updated_at`, + ) + .run( + chatJid, + override.role, + override.agentType, + override.agentConfig ? JSON.stringify(override.agentConfig) : null, + override.createdAt, + override.updatedAt, + ); +} + +export function syncRoomRoleOverridesForRoom( + database: Database, + chatJid: string, + roomMode: RoomMode, + ownerAgentType: AgentType, + ownerAgentConfig?: RegisteredGroup['agentConfig'], + addedAt?: string, +): void { + const now = addedAt ?? new Date().toISOString(); + const existingOverrides = getStoredRoomRoleOverrideRows(database, chatJid); + const existingOwnerOverride = existingOverrides.get('owner'); + const ownerMetadata = getRegisteredGroupCapabilityMetadata( + database, + chatJid, + ownerAgentType, + ); + upsertRoomRoleOverride(database, chatJid, { + role: 'owner', + agentType: ownerAgentType, + agentConfig: + ownerAgentConfig ?? + (existingOwnerOverride?.agentType === ownerAgentType + ? existingOwnerOverride.agentConfig + : undefined) ?? + ownerMetadata?.agentConfig, + createdAt: + (existingOwnerOverride?.agentType === ownerAgentType + ? existingOwnerOverride.createdAt + : undefined) ?? + ownerMetadata?.added_at ?? + now, + updatedAt: now, + }); + + if (roomMode === 'tribunal') { + const reviewerAgentType = + ownerAgentType === REVIEWER_AGENT_TYPE + ? OWNER_AGENT_TYPE + : REVIEWER_AGENT_TYPE; + const reviewerMetadata = getRegisteredGroupCapabilityMetadata( + database, + chatJid, + reviewerAgentType, + ); + const existingReviewerOverride = existingOverrides.get('reviewer'); + upsertRoomRoleOverride(database, chatJid, { + role: 'reviewer', + agentType: reviewerAgentType, + agentConfig: + (existingReviewerOverride?.agentType === reviewerAgentType + ? existingReviewerOverride.agentConfig + : undefined) ?? reviewerMetadata?.agentConfig, + createdAt: + (existingReviewerOverride?.agentType === reviewerAgentType + ? existingReviewerOverride.createdAt + : undefined) ?? + reviewerMetadata?.added_at ?? + now, + updatedAt: now, + }); + } else { + database + .prepare( + `DELETE FROM room_role_overrides + WHERE chat_jid = ? + AND role = 'reviewer'`, + ) + .run(chatJid); + } +} + export function updateStoredRoomMetadata( database: Database, chatJid: string, diff --git a/src/db/router-state.ts b/src/db/router-state.ts index f12a2b1..a04fe04 100644 --- a/src/db/router-state.ts +++ b/src/db/router-state.ts @@ -65,6 +65,23 @@ export function setRouterStateForServiceInDatabase( .run(prefixedKey, value); } +export function getLegacyRouterStateKeysFromDatabase( + database: Database, +): string[] { + const rows = database + .prepare( + `SELECT key + FROM router_state + WHERE key IN ('last_timestamp', 'last_agent_timestamp') + OR key LIKE '%:last_timestamp' + OR key LIKE '%:last_agent_timestamp' + ORDER BY key`, + ) + .all() as Array<{ key: string }>; + + return rows.map((row) => row.key); +} + export function getLastRespondingAgentTypeFromDatabase( database: Database, chatJid: string, diff --git a/src/db/schema.ts b/src/db/schema.ts index 4f76875..020ed68 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -1,23 +1,20 @@ import { Database } from 'bun:sqlite'; -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 { SERVICE_SESSION_SCOPE, normalizeServiceId } from '../config.js'; import { inferAgentTypeFromServiceShadow, resolveRoleServiceShadow, } from '../role-service-shadow.js'; import { - resolveStablePairedTaskOwnerAgentType, - resolveStableReviewerAgentType, - resolveStableRoomRoleAgentType, -} from './legacy-rebuilds.js'; + canonicalizeChannelOwnerLeaseMetadata, + canonicalizePairedTaskMetadata, + canonicalizeServiceHandoffMetadata, + inferExecutionLeaseServiceIdFromCanonicalMetadata, +} from './canonical-role-metadata.js'; +import { + backfillPairedTurnAttemptsFromTurns, + buildPairedTurnAttemptId, +} from './paired-turn-attempts.js'; import { normalizeStoredAgentType } from './room-registration.js'; import { backfillLegacyServiceSessions, @@ -41,6 +38,20 @@ export function tableHasColumn( return getTableColumns(database, tableName).includes(columnName); } +function getTableSql(database: Database, tableName: string): string | null { + const row = database + .prepare( + ` + SELECT sql + FROM sqlite_master + WHERE type = 'table' + AND name = ? + `, + ) + .get(tableName) as { sql?: string | null } | undefined; + return row?.sql ?? null; +} + function tryExecMigration(database: Database, sql: string): void { try { database.exec(sql); @@ -49,6 +60,125 @@ function tryExecMigration(database: Database, sql: string): void { } } +function buildPairedTurnAttemptIdSql( + turnIdExpr: string, + attemptNoExpr: string, +): string { + return `${turnIdExpr} || ':attempt:' || CAST(${attemptNoExpr} AS TEXT)`; +} + +function buildPairedTurnAttemptParentIdSql( + turnIdExpr: string, + attemptNoExpr: string, +): string { + return `CASE + WHEN ${attemptNoExpr} <= 1 THEN NULL + ELSE ${buildPairedTurnAttemptIdSql(turnIdExpr, `(${attemptNoExpr}) - 1`)} + END`; +} + +function buildPairedTurnAttemptParentHandoffMatchSql(args: { + parentHandoffIdExpr: string; + turnIdExpr: string; + parentAttemptIdExpr: string; + attemptNoExpr: string; +}): string { + return `EXISTS ( + SELECT 1 + FROM service_handoffs + WHERE service_handoffs.id = ${args.parentHandoffIdExpr} + AND service_handoffs.turn_id = ${args.turnIdExpr} + AND service_handoffs.status = 'failed' + AND ( + service_handoffs.turn_attempt_id = ${args.parentAttemptIdExpr} + OR ( + service_handoffs.turn_attempt_id IS NULL + AND service_handoffs.turn_attempt_no = (${args.attemptNoExpr}) - 1 + ) + ) + )`; +} + +function buildPairedTurnAttemptContinuationHandoffMatchSql(args: { + continuationHandoffIdExpr: string; + turnIdExpr: string; + attemptIdExpr: string; + attemptNoExpr: string; +}): string { + return `EXISTS ( + SELECT 1 + FROM service_handoffs + WHERE service_handoffs.id = ${args.continuationHandoffIdExpr} + AND service_handoffs.turn_id = ${args.turnIdExpr} + AND ( + service_handoffs.turn_attempt_id = ${args.attemptIdExpr} + OR ( + service_handoffs.turn_attempt_id IS NULL + AND service_handoffs.turn_attempt_no = ${args.attemptNoExpr} + ) + ) + )`; +} + +function tableHasForeignKey( + database: Database, + args: { + tableName: string; + referencedTable: string; + fromColumns: string[]; + toColumns: string[]; + onDelete?: string; + }, +): boolean { + const rows = database + .prepare(`PRAGMA foreign_key_list(${args.tableName})`) + .all() as Array<{ + id: number; + seq: number; + table: string; + from: string; + to: string; + on_delete: string; + }>; + if (rows.length === 0) { + return false; + } + + const byId = new Map(); + for (const row of rows) { + const group = byId.get(row.id) ?? []; + group.push(row); + byId.set(row.id, group); + } + + for (const group of byId.values()) { + const sorted = [...group].sort((left, right) => left.seq - right.seq); + if (sorted.length !== args.fromColumns.length) { + continue; + } + if (sorted[0]!.table !== args.referencedTable) { + continue; + } + if ( + args.onDelete && + sorted[0]!.on_delete.toUpperCase() !== args.onDelete.toUpperCase() + ) { + continue; + } + + const matches = sorted.every((row, index) => { + return ( + row.from === args.fromColumns[index] && row.to === args.toColumns[index] + ); + }); + if (matches) { + return true; + } + } + + return false; +} + function backfillMessageSeq(database: Database): void { const rows = database .prepare( @@ -99,12 +229,14 @@ interface LegacyExecutionLeaseServiceRow { interface StoredPairedTaskServiceRow { rowid: number; + id: string; 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; + arbiter_agent_type?: string | null; } interface StoredChannelOwnerLeaseServiceRow { @@ -131,6 +263,1916 @@ interface StoredServiceHandoffServiceRow { intended_role?: string | null; } +interface PairedTurnAttemptTimingRow { + turn_id: string; + attempt_no: number; + created_at: string; +} + +interface PairedTurnAttemptTiming { + attemptNo: number; + createdAtMs: number; +} + +function parseBackfillTimestampMs( + value: string | null | undefined, +): number | null { + if (!value) { + return null; + } + + const trimmed = value.trim(); + if (trimmed.length === 0) { + return null; + } + + let normalized = trimmed.includes('T') ? trimmed : trimmed.replace(' ', 'T'); + if (!/[zZ]|[+-]\d{2}:\d{2}$/.test(normalized)) { + normalized = `${normalized}Z`; + } + + const parsed = Date.parse(normalized); + return Number.isNaN(parsed) ? null : parsed; +} + +function getPairedTurnAttemptTimingsByTurnId( + database: Database, +): Map { + const rows = database + .prepare( + ` + SELECT turn_id, attempt_no, created_at + FROM paired_turn_attempts + WHERE attempt_no >= 1 + ORDER BY turn_id ASC, attempt_no ASC + `, + ) + .all() as PairedTurnAttemptTimingRow[]; + const timingsByTurnId = new Map(); + + for (const row of rows) { + const createdAtMs = parseBackfillTimestampMs(row.created_at); + if (createdAtMs === null) { + throw new Error( + `paired_turn_attempts(${row.turn_id}, attempt=${row.attempt_no}) has an invalid created_at timestamp`, + ); + } + const timings = timingsByTurnId.get(row.turn_id) ?? []; + timings.push({ + attemptNo: row.attempt_no, + createdAtMs, + }); + timingsByTurnId.set(row.turn_id, timings); + } + + return timingsByTurnId; +} + +function resolveAttemptNoForBackfill( + args: { + turnId: string; + eventTimestamp: string | null | undefined; + rowLabel: string; + }, + timingsByTurnId: Map, +): number | null { + const attemptTimings = timingsByTurnId.get(args.turnId) ?? []; + if (attemptTimings.length === 0) { + return null; + } + + if (attemptTimings.length === 1) { + return attemptTimings[0]!.attemptNo; + } + + const eventAtMs = parseBackfillTimestampMs(args.eventTimestamp); + if (eventAtMs === null) { + throw new Error( + `${args.rowLabel} has turn_id=${args.turnId} but no timestamp that can be mapped to a specific attempt`, + ); + } + + const exactMatches = attemptTimings.filter((timing, index) => { + const nextTiming = attemptTimings[index + 1]; + return ( + eventAtMs >= timing.createdAtMs && + (nextTiming === undefined || eventAtMs < nextTiming.createdAtMs) + ); + }); + if (exactMatches.length === 1) { + return exactMatches[0]!.attemptNo; + } + if (exactMatches.length > 1) { + throw new Error( + `${args.rowLabel} has an ambiguous exact timestamp match for turn_id=${args.turnId}`, + ); + } + + const eventAtSecond = Math.floor(eventAtMs / 1000); + const secondPrecisionMatches = attemptTimings.filter((timing, index) => { + const nextTiming = attemptTimings[index + 1]; + const timingSecond = Math.floor(timing.createdAtMs / 1000); + const nextTimingSecond = + nextTiming === undefined + ? null + : Math.floor(nextTiming.createdAtMs / 1000); + return ( + eventAtSecond >= timingSecond && + (nextTimingSecond === null || eventAtSecond < nextTimingSecond) + ); + }); + if (secondPrecisionMatches.length === 1) { + return secondPrecisionMatches[0]!.attemptNo; + } + if (secondPrecisionMatches.length > 1) { + throw new Error( + `${args.rowLabel} has an ambiguous second-precision timestamp match for turn_id=${args.turnId}`, + ); + } + + throw new Error( + `${args.rowLabel} could not be mapped to a specific attempt for turn_id=${args.turnId}`, + ); +} + +function backfillPairedTurnAttemptIds(database: Database): void { + if (!tableHasColumn(database, 'paired_turn_attempts', 'attempt_id')) { + return; + } + + database.exec(` + UPDATE paired_turn_attempts + SET attempt_id = ${buildPairedTurnAttemptIdSql('turn_id', 'attempt_no')} + WHERE attempt_id IS NULL + OR TRIM(attempt_id) = '' + `); +} + +function backfillPairedTurnAttemptParentIds(database: Database): void { + if (!tableHasColumn(database, 'paired_turn_attempts', 'parent_attempt_id')) { + return; + } + + database.exec(` + UPDATE paired_turn_attempts + SET parent_attempt_id = ${buildPairedTurnAttemptParentIdSql('turn_id', 'attempt_no')} + WHERE attempt_no > 1 + AND EXISTS ( + SELECT 1 + FROM paired_turn_attempts previous_attempt + WHERE previous_attempt.turn_id = paired_turn_attempts.turn_id + AND previous_attempt.attempt_no = paired_turn_attempts.attempt_no - 1 + ) + AND ( + parent_attempt_id IS NULL + OR TRIM(parent_attempt_id) = '' + OR parent_attempt_id != ${buildPairedTurnAttemptParentIdSql('turn_id', 'attempt_no')} + ) + `); + + database.exec(` + UPDATE paired_turn_attempts + SET parent_attempt_id = NULL + WHERE attempt_no <= 1 + AND parent_attempt_id IS NOT NULL + `); +} + +function backfillPairedTurnAttemptActiveRunIds(database: Database): void { + if (!tableHasColumn(database, 'paired_turn_attempts', 'active_run_id')) { + return; + } + + const pairedTurnsActiveRunIdExists = tableHasColumn( + database, + 'paired_turns', + 'active_run_id', + ); + const leaseTurnIdExists = tableHasColumn( + database, + 'paired_task_execution_leases', + 'turn_id', + ); + const leaseTurnAttemptNoExists = tableHasColumn( + database, + 'paired_task_execution_leases', + 'turn_attempt_no', + ); + const leaseActiveRunIdSql = leaseTurnIdExists + ? leaseTurnAttemptNoExists + ? `COALESCE( + ( + SELECT paired_task_execution_leases.claimed_run_id + FROM paired_task_execution_leases + WHERE paired_task_execution_leases.turn_id = paired_turn_attempts.turn_id + AND paired_task_execution_leases.turn_attempt_no = paired_turn_attempts.attempt_no + ORDER BY paired_task_execution_leases.updated_at DESC, + paired_task_execution_leases.claimed_at DESC + LIMIT 1 + ), + ( + SELECT paired_task_execution_leases.claimed_run_id + FROM paired_task_execution_leases + WHERE paired_task_execution_leases.turn_id = paired_turn_attempts.turn_id + AND paired_task_execution_leases.turn_attempt_no IS NULL + ORDER BY paired_task_execution_leases.updated_at DESC, + paired_task_execution_leases.claimed_at DESC + LIMIT 1 + ) + )` + : `( + SELECT paired_task_execution_leases.claimed_run_id + FROM paired_task_execution_leases + WHERE paired_task_execution_leases.turn_id = paired_turn_attempts.turn_id + ORDER BY paired_task_execution_leases.updated_at DESC, + paired_task_execution_leases.claimed_at DESC + LIMIT 1 + )` + : 'NULL'; + const legacyTurnActiveRunIdSql = pairedTurnsActiveRunIdExists + ? `( + SELECT paired_turns.active_run_id + FROM paired_turns + WHERE paired_turns.turn_id = paired_turn_attempts.turn_id + )` + : 'NULL'; + + database.exec(` + UPDATE paired_turn_attempts + SET active_run_id = COALESCE( + active_run_id, + CASE + WHEN state = 'running' + THEN COALESCE( + ${leaseActiveRunIdSql}, + ${legacyTurnActiveRunIdSql} + ) + ELSE NULL + END + ) + WHERE state = 'running' + AND (active_run_id IS NULL OR TRIM(active_run_id) = '') + `); + + database.exec(` + UPDATE paired_turn_attempts + SET active_run_id = NULL + WHERE state != 'running' + AND active_run_id IS NOT NULL + `); +} + +function backfillPairedTurnAttemptEntityIds(database: Database): void { + if ( + tableHasColumn(database, 'paired_turn_reservations', 'turn_attempt_id') && + tableHasColumn(database, 'paired_turn_reservations', 'turn_attempt_no') + ) { + database.exec(` + UPDATE paired_turn_reservations + SET turn_attempt_id = ( + SELECT paired_turn_attempts.attempt_id + FROM paired_turn_attempts + WHERE paired_turn_attempts.turn_id = paired_turn_reservations.turn_id + AND paired_turn_attempts.attempt_no = paired_turn_reservations.turn_attempt_no + ) + WHERE turn_attempt_no IS NOT NULL + AND (turn_attempt_id IS NULL OR TRIM(turn_attempt_id) = '') + `); + } + + if ( + tableHasColumn( + database, + 'paired_task_execution_leases', + 'turn_attempt_id', + ) && + tableHasColumn(database, 'paired_task_execution_leases', 'turn_attempt_no') + ) { + database.exec(` + UPDATE paired_task_execution_leases + SET turn_attempt_id = ( + SELECT paired_turn_attempts.attempt_id + FROM paired_turn_attempts + WHERE paired_turn_attempts.turn_id = paired_task_execution_leases.turn_id + AND paired_turn_attempts.attempt_no = paired_task_execution_leases.turn_attempt_no + ) + WHERE turn_attempt_no IS NOT NULL + AND (turn_attempt_id IS NULL OR TRIM(turn_attempt_id) = '') + `); + } + + if ( + tableHasColumn(database, 'service_handoffs', 'turn_attempt_id') && + tableHasColumn(database, 'service_handoffs', 'turn_attempt_no') + ) { + database.exec(` + UPDATE service_handoffs + SET turn_attempt_id = ( + SELECT paired_turn_attempts.attempt_id + FROM paired_turn_attempts + WHERE paired_turn_attempts.turn_id = service_handoffs.turn_id + AND paired_turn_attempts.attempt_no = service_handoffs.turn_attempt_no + ) + WHERE turn_attempt_no IS NOT NULL + AND (turn_attempt_id IS NULL OR TRIM(turn_attempt_id) = '') + `); + } +} + +function backfillPairedTurnAttemptProvenance(database: Database): void { + const timingsByTurnId = getPairedTurnAttemptTimingsByTurnId(database); + + if (tableHasColumn(database, 'paired_turn_reservations', 'turn_attempt_no')) { + const hasTurnAttemptIdColumn = tableHasColumn( + database, + 'paired_turn_reservations', + 'turn_attempt_id', + ); + const rows = database + .prepare( + ` + SELECT rowid, turn_id, status, created_at, updated_at, consumed_at + FROM paired_turn_reservations + WHERE turn_id IS NOT NULL + AND turn_attempt_no IS NULL + `, + ) + .all() as Array<{ + rowid: number; + turn_id: string; + status: string; + created_at: string | null; + updated_at: string | null; + consumed_at: string | null; + }>; + const update = hasTurnAttemptIdColumn + ? database.prepare( + 'UPDATE paired_turn_reservations SET turn_attempt_id = ?, turn_attempt_no = ? WHERE rowid = ?', + ) + : database.prepare( + 'UPDATE paired_turn_reservations SET turn_attempt_no = ? WHERE rowid = ?', + ); + + for (const row of rows) { + if (row.status !== 'completed') { + continue; + } + const attemptNo = resolveAttemptNoForBackfill( + { + turnId: row.turn_id, + eventTimestamp: row.consumed_at ?? row.updated_at ?? row.created_at, + rowLabel: `paired_turn_reservations(rowid=${row.rowid})`, + }, + timingsByTurnId, + ); + if (attemptNo !== null) { + const attemptId = buildPairedTurnAttemptId(row.turn_id, attemptNo); + if (hasTurnAttemptIdColumn) { + update.run(attemptId, attemptNo, row.rowid); + } else { + update.run(attemptNo, row.rowid); + } + } + } + } + + if ( + tableHasColumn(database, 'paired_task_execution_leases', 'turn_attempt_no') + ) { + const hasTurnAttemptIdColumn = tableHasColumn( + database, + 'paired_task_execution_leases', + 'turn_attempt_id', + ); + const rows = database + .prepare( + ` + SELECT rowid, turn_id, claimed_at, updated_at + FROM paired_task_execution_leases + WHERE turn_id IS NOT NULL + AND turn_attempt_no IS NULL + `, + ) + .all() as Array<{ + rowid: number; + turn_id: string; + claimed_at: string | null; + updated_at: string | null; + }>; + const update = hasTurnAttemptIdColumn + ? database.prepare( + 'UPDATE paired_task_execution_leases SET turn_attempt_id = ?, turn_attempt_no = ? WHERE rowid = ?', + ) + : database.prepare( + 'UPDATE paired_task_execution_leases SET turn_attempt_no = ? WHERE rowid = ?', + ); + + for (const row of rows) { + const attemptNo = resolveAttemptNoForBackfill( + { + turnId: row.turn_id, + eventTimestamp: row.updated_at ?? row.claimed_at, + rowLabel: `paired_task_execution_leases(rowid=${row.rowid})`, + }, + timingsByTurnId, + ); + if (attemptNo !== null) { + const attemptId = buildPairedTurnAttemptId(row.turn_id, attemptNo); + if (hasTurnAttemptIdColumn) { + update.run(attemptId, attemptNo, row.rowid); + } else { + update.run(attemptNo, row.rowid); + } + } + } + } + + if (tableHasColumn(database, 'service_handoffs', 'turn_attempt_no')) { + const hasTurnAttemptIdColumn = tableHasColumn( + database, + 'service_handoffs', + 'turn_attempt_id', + ); + const rows = database + .prepare( + ` + SELECT id, turn_id, created_at, claimed_at, completed_at + FROM service_handoffs + WHERE turn_id IS NOT NULL + AND turn_attempt_no IS NULL + `, + ) + .all() as Array<{ + id: number; + turn_id: string; + created_at: string | null; + claimed_at: string | null; + completed_at: string | null; + }>; + const update = hasTurnAttemptIdColumn + ? database.prepare( + 'UPDATE service_handoffs SET turn_attempt_id = ?, turn_attempt_no = ? WHERE id = ?', + ) + : database.prepare( + 'UPDATE service_handoffs SET turn_attempt_no = ? WHERE id = ?', + ); + + for (const row of rows) { + const attemptNo = resolveAttemptNoForBackfill( + { + turnId: row.turn_id, + eventTimestamp: row.completed_at ?? row.claimed_at ?? row.created_at, + rowLabel: `service_handoffs(id=${row.id})`, + }, + timingsByTurnId, + ); + if (attemptNo !== null) { + const attemptId = buildPairedTurnAttemptId(row.turn_id, attemptNo); + if (hasTurnAttemptIdColumn) { + update.run(attemptId, attemptNo, row.id); + } else { + update.run(attemptNo, row.id); + } + } + } + } +} + +function assertPairedTurnAttemptProvenanceIntegrity(database: Database): void { + if (tableHasColumn(database, 'paired_turn_attempts', 'attempt_id')) { + const invalidAttemptIdRow = database + .prepare( + ` + SELECT turn_id, attempt_no, attempt_id + FROM paired_turn_attempts + WHERE attempt_id IS NULL + OR attempt_id != ${buildPairedTurnAttemptIdSql('turn_id', 'attempt_no')} + LIMIT 1 + `, + ) + .get() as + | { + turn_id: string; + attempt_no: number; + attempt_id: string | null; + } + | undefined; + if (invalidAttemptIdRow) { + throw new Error( + `paired_turn_attempts(${invalidAttemptIdRow.turn_id}, attempt=${invalidAttemptIdRow.attempt_no}) has invalid attempt_id provenance`, + ); + } + } + + if (tableHasColumn(database, 'paired_turn_attempts', 'parent_attempt_id')) { + const invalidParentAttemptRow = database + .prepare( + ` + SELECT turn_id, attempt_no, parent_attempt_id + FROM paired_turn_attempts + WHERE ( + attempt_no <= 1 + AND parent_attempt_id IS NOT NULL + ) + OR ( + attempt_no > 1 + AND NOT EXISTS ( + SELECT 1 + FROM paired_turn_attempts previous_attempt + WHERE previous_attempt.turn_id = paired_turn_attempts.turn_id + AND previous_attempt.attempt_no = paired_turn_attempts.attempt_no - 1 + ) + ) + OR ( + attempt_no > 1 + AND ( + parent_attempt_id IS NULL + OR parent_attempt_id != ${buildPairedTurnAttemptParentIdSql('turn_id', 'attempt_no')} + OR NOT EXISTS ( + SELECT 1 + FROM paired_turn_attempts previous_attempt + WHERE COALESCE( + previous_attempt.attempt_id, + ${buildPairedTurnAttemptIdSql( + 'previous_attempt.turn_id', + 'previous_attempt.attempt_no', + )} + ) = paired_turn_attempts.parent_attempt_id + AND previous_attempt.turn_id = paired_turn_attempts.turn_id + AND previous_attempt.attempt_no = paired_turn_attempts.attempt_no - 1 + ) + ) + ) + LIMIT 1 + `, + ) + .get() as + | { + turn_id: string; + attempt_no: number; + parent_attempt_id: string | null; + } + | undefined; + if (invalidParentAttemptRow) { + throw new Error( + `paired_turn_attempts(${invalidParentAttemptRow.turn_id}, attempt=${invalidParentAttemptRow.attempt_no}) has invalid parent_attempt_id provenance`, + ); + } + } + + if (tableHasColumn(database, 'paired_turn_attempts', 'parent_handoff_id')) { + const invalidParentHandoffRow = database + .prepare( + ` + SELECT turn_id, attempt_no, parent_handoff_id + FROM paired_turn_attempts + WHERE parent_handoff_id IS NOT NULL + AND ( + attempt_no <= 1 + OR NOT ${buildPairedTurnAttemptParentHandoffMatchSql({ + parentHandoffIdExpr: 'paired_turn_attempts.parent_handoff_id', + turnIdExpr: 'paired_turn_attempts.turn_id', + parentAttemptIdExpr: 'paired_turn_attempts.parent_attempt_id', + attemptNoExpr: 'paired_turn_attempts.attempt_no', + })} + ) + LIMIT 1 + `, + ) + .get() as + | { + turn_id: string; + attempt_no: number; + parent_handoff_id: number | null; + } + | undefined; + if (invalidParentHandoffRow) { + throw new Error( + `paired_turn_attempts(${invalidParentHandoffRow.turn_id}, attempt=${invalidParentHandoffRow.attempt_no}) has invalid parent_handoff_id provenance`, + ); + } + } + + if ( + tableHasColumn(database, 'paired_turn_attempts', 'continuation_handoff_id') + ) { + const invalidContinuationHandoffRow = database + .prepare( + ` + SELECT turn_id, attempt_no, continuation_handoff_id + FROM paired_turn_attempts + WHERE continuation_handoff_id IS NOT NULL + AND NOT ${buildPairedTurnAttemptContinuationHandoffMatchSql({ + continuationHandoffIdExpr: + 'paired_turn_attempts.continuation_handoff_id', + turnIdExpr: 'paired_turn_attempts.turn_id', + attemptIdExpr: + "COALESCE(paired_turn_attempts.attempt_id, paired_turn_attempts.turn_id || ':attempt:' || CAST(paired_turn_attempts.attempt_no AS TEXT))", + attemptNoExpr: 'paired_turn_attempts.attempt_no', + })} + LIMIT 1 + `, + ) + .get() as + | { + turn_id: string; + attempt_no: number; + continuation_handoff_id: number | null; + } + | undefined; + if (invalidContinuationHandoffRow) { + throw new Error( + `paired_turn_attempts(${invalidContinuationHandoffRow.turn_id}, attempt=${invalidContinuationHandoffRow.attempt_no}) has invalid continuation_handoff_id provenance`, + ); + } + } + + const invalidAttemptRow = database + .prepare( + ` + SELECT turn_id, attempt_no + FROM paired_turn_attempts + WHERE NOT EXISTS ( + SELECT 1 + FROM paired_turns + WHERE paired_turns.turn_id = paired_turn_attempts.turn_id + AND paired_turns.task_id = paired_turn_attempts.task_id + AND paired_turns.task_updated_at = paired_turn_attempts.task_updated_at + AND paired_turns.role = paired_turn_attempts.role + AND paired_turns.intent_kind = paired_turn_attempts.intent_kind + ) + LIMIT 1 + `, + ) + .get() as + | { + turn_id: string; + attempt_no: number; + } + | undefined; + if (invalidAttemptRow) { + throw new Error( + `paired_turn_attempts(${invalidAttemptRow.turn_id}, attempt=${invalidAttemptRow.attempt_no}) does not match its paired_turns identity`, + ); + } + + if (tableHasColumn(database, 'paired_turn_attempts', 'active_run_id')) { + const invalidAttemptActiveRunRow = database + .prepare( + ` + SELECT turn_id, attempt_no + FROM paired_turn_attempts + WHERE ( + state = 'running' + AND ( + active_run_id IS NULL + OR TRIM(active_run_id) = '' + ) + ) + OR ( + state != 'running' + AND active_run_id IS NOT NULL + ) + LIMIT 1 + `, + ) + .get() as + | { + turn_id: string; + attempt_no: number; + } + | undefined; + if (invalidAttemptActiveRunRow) { + throw new Error( + `paired_turn_attempts(${invalidAttemptActiveRunRow.turn_id}, attempt=${invalidAttemptActiveRunRow.attempt_no}) has invalid active_run_id provenance`, + ); + } + } + + if (tableHasColumn(database, 'paired_turn_reservations', 'turn_attempt_no')) { + const invalidReservation = database + .prepare( + ` + SELECT rowid + FROM paired_turn_reservations + WHERE turn_attempt_no IS NOT NULL + AND ( + turn_id IS NULL + OR (turn_attempt_id IS NULL AND ${tableHasColumn(database, 'paired_turn_reservations', 'turn_attempt_id') ? '1 = 1' : '0 = 1'}) + OR NOT EXISTS ( + SELECT 1 + FROM paired_turn_attempts + WHERE paired_turn_attempts.turn_id = paired_turn_reservations.turn_id + AND paired_turn_attempts.attempt_no = paired_turn_reservations.turn_attempt_no + ${ + tableHasColumn( + database, + 'paired_turn_reservations', + 'turn_attempt_id', + ) + ? 'AND paired_turn_attempts.attempt_id = paired_turn_reservations.turn_attempt_id' + : '' + } + AND paired_turn_attempts.task_id = paired_turn_reservations.task_id + AND paired_turn_attempts.task_updated_at = paired_turn_reservations.task_updated_at + AND paired_turn_attempts.role = paired_turn_reservations.turn_role + AND paired_turn_attempts.intent_kind = paired_turn_reservations.intent_kind + ) + ) + LIMIT 1 + `, + ) + .get() as { rowid: number } | undefined; + if (invalidReservation) { + throw new Error( + `paired_turn_reservations(rowid=${invalidReservation.rowid}) has invalid paired_turn_attempt provenance`, + ); + } + } + + if ( + tableHasColumn(database, 'paired_task_execution_leases', 'turn_attempt_no') + ) { + const invalidLease = database + .prepare( + ` + SELECT rowid + FROM paired_task_execution_leases + WHERE turn_attempt_no IS NOT NULL + AND ( + turn_id IS NULL + OR (turn_attempt_id IS NULL AND ${tableHasColumn(database, 'paired_task_execution_leases', 'turn_attempt_id') ? '1 = 1' : '0 = 1'}) + OR NOT EXISTS ( + SELECT 1 + FROM paired_turn_attempts + WHERE paired_turn_attempts.turn_id = paired_task_execution_leases.turn_id + AND paired_turn_attempts.attempt_no = paired_task_execution_leases.turn_attempt_no + ${ + tableHasColumn( + database, + 'paired_task_execution_leases', + 'turn_attempt_id', + ) + ? 'AND paired_turn_attempts.attempt_id = paired_task_execution_leases.turn_attempt_id' + : '' + } + AND paired_turn_attempts.task_id = paired_task_execution_leases.task_id + AND paired_turn_attempts.task_updated_at = paired_task_execution_leases.task_updated_at + AND paired_turn_attempts.role = paired_task_execution_leases.role + AND paired_turn_attempts.intent_kind = paired_task_execution_leases.intent_kind + ) + ) + LIMIT 1 + `, + ) + .get() as { rowid: number } | undefined; + if (invalidLease) { + throw new Error( + `paired_task_execution_leases(rowid=${invalidLease.rowid}) has invalid paired_turn_attempt provenance`, + ); + } + } + + if (tableHasColumn(database, 'service_handoffs', 'turn_attempt_no')) { + const invalidHandoff = database + .prepare( + ` + SELECT id + FROM service_handoffs + WHERE turn_attempt_no IS NOT NULL + AND ( + turn_id IS NULL + OR (turn_attempt_id IS NULL AND ${tableHasColumn(database, 'service_handoffs', 'turn_attempt_id') ? '1 = 1' : '0 = 1'}) + OR paired_task_id IS NULL + OR paired_task_updated_at IS NULL + OR turn_role IS NULL + OR turn_intent_kind IS NULL + OR NOT EXISTS ( + SELECT 1 + FROM paired_turn_attempts + WHERE paired_turn_attempts.turn_id = service_handoffs.turn_id + AND paired_turn_attempts.attempt_no = service_handoffs.turn_attempt_no + ${ + tableHasColumn( + database, + 'service_handoffs', + 'turn_attempt_id', + ) + ? 'AND paired_turn_attempts.attempt_id = service_handoffs.turn_attempt_id' + : '' + } + AND paired_turn_attempts.task_id = service_handoffs.paired_task_id + AND paired_turn_attempts.task_updated_at = service_handoffs.paired_task_updated_at + AND paired_turn_attempts.role = service_handoffs.turn_role + AND paired_turn_attempts.intent_kind = service_handoffs.turn_intent_kind + ) + ) + LIMIT 1 + `, + ) + .get() as { id: number } | undefined; + if (invalidHandoff) { + throw new Error( + `service_handoffs(id=${invalidHandoff.id}) has invalid paired_turn_attempt provenance`, + ); + } + } +} + +function rebuildPairedTurnAttemptsWithForeignKeys(database: Database): void { + const tableSql = getTableSql(database, 'paired_turn_attempts') ?? ''; + if ( + tableHasColumn(database, 'paired_turn_attempts', 'attempt_id') && + tableHasColumn(database, 'paired_turn_attempts', 'parent_attempt_id') && + tableHasColumn(database, 'paired_turn_attempts', 'parent_handoff_id') && + tableHasColumn( + database, + 'paired_turn_attempts', + 'continuation_handoff_id', + ) && + tableSql.includes('attempt_id TEXT NOT NULL PRIMARY KEY') && + tableSql.includes('parent_attempt_id TEXT') && + tableSql.includes('parent_handoff_id INTEGER') && + tableSql.includes('continuation_handoff_id INTEGER') && + tableSql.includes('UNIQUE (turn_id, attempt_no)') && + tableHasForeignKey(database, { + tableName: 'paired_turn_attempts', + referencedTable: 'paired_turn_attempts', + fromColumns: ['parent_attempt_id'], + toColumns: ['attempt_id'], + onDelete: 'CASCADE', + }) && + tableHasForeignKey(database, { + tableName: 'paired_turn_attempts', + referencedTable: 'service_handoffs', + fromColumns: ['parent_handoff_id'], + toColumns: ['id'], + onDelete: 'SET NULL', + }) && + tableHasForeignKey(database, { + tableName: 'paired_turn_attempts', + referencedTable: 'service_handoffs', + fromColumns: ['continuation_handoff_id'], + toColumns: ['id'], + onDelete: 'SET NULL', + }) && + tableHasForeignKey(database, { + tableName: 'paired_turn_attempts', + referencedTable: 'paired_turns', + fromColumns: ['turn_id'], + toColumns: ['turn_id'], + onDelete: 'CASCADE', + }) + ) { + return; + } + + const parentAttemptIdSelectSql = tableHasColumn( + database, + 'paired_turn_attempts', + 'parent_attempt_id', + ) + ? `COALESCE( + parent_attempt_id, + CASE + WHEN attempt_no > 1 + AND EXISTS ( + SELECT 1 + FROM paired_turn_attempts previous_attempt + WHERE previous_attempt.turn_id = paired_turn_attempts.turn_id + AND previous_attempt.attempt_no = paired_turn_attempts.attempt_no - 1 + ) + THEN ${buildPairedTurnAttemptParentIdSql('turn_id', 'attempt_no')} + ELSE NULL + END + )` + : `CASE + WHEN attempt_no > 1 + AND EXISTS ( + SELECT 1 + FROM paired_turn_attempts previous_attempt + WHERE previous_attempt.turn_id = paired_turn_attempts.turn_id + AND previous_attempt.attempt_no = paired_turn_attempts.attempt_no - 1 + ) + THEN ${buildPairedTurnAttemptParentIdSql('turn_id', 'attempt_no')} + ELSE NULL + END`; + const parentHandoffIdSelectSql = tableHasColumn( + database, + 'paired_turn_attempts', + 'parent_handoff_id', + ) + ? 'parent_handoff_id' + : 'NULL'; + const continuationHandoffIdSelectSql = tableHasColumn( + database, + 'paired_turn_attempts', + 'continuation_handoff_id', + ) + ? 'continuation_handoff_id' + : 'NULL'; + const activeRunIdSelectSql = tableHasColumn( + database, + 'paired_turn_attempts', + 'active_run_id', + ) + ? `CASE + WHEN state = 'running' THEN active_run_id + ELSE NULL + END` + : `CASE + WHEN state = 'running' + THEN ${ + tableHasColumn(database, 'paired_turns', 'active_run_id') + ? `COALESCE( + ${ + tableHasColumn( + database, + 'paired_task_execution_leases', + 'turn_attempt_no', + ) + ? `COALESCE( + ( + SELECT paired_task_execution_leases.claimed_run_id + FROM paired_task_execution_leases + WHERE paired_task_execution_leases.turn_id = paired_turn_attempts.turn_id + AND paired_task_execution_leases.turn_attempt_no = paired_turn_attempts.attempt_no + ORDER BY paired_task_execution_leases.updated_at DESC, + paired_task_execution_leases.claimed_at DESC + LIMIT 1 + ), + ( + SELECT paired_task_execution_leases.claimed_run_id + FROM paired_task_execution_leases + WHERE paired_task_execution_leases.turn_id = paired_turn_attempts.turn_id + AND paired_task_execution_leases.turn_attempt_no IS NULL + ORDER BY paired_task_execution_leases.updated_at DESC, + paired_task_execution_leases.claimed_at DESC + LIMIT 1 + ) + )` + : `( + SELECT paired_task_execution_leases.claimed_run_id + FROM paired_task_execution_leases + WHERE paired_task_execution_leases.turn_id = paired_turn_attempts.turn_id + ORDER BY paired_task_execution_leases.updated_at DESC, + paired_task_execution_leases.claimed_at DESC + LIMIT 1 + )` + }, + ( + SELECT paired_turns.active_run_id + FROM paired_turns + WHERE paired_turns.turn_id = paired_turn_attempts.turn_id + ) + )` + : `( + ${ + tableHasColumn( + database, + 'paired_task_execution_leases', + 'turn_attempt_no', + ) + ? `COALESCE( + ( + SELECT paired_task_execution_leases.claimed_run_id + FROM paired_task_execution_leases + WHERE paired_task_execution_leases.turn_id = paired_turn_attempts.turn_id + AND paired_task_execution_leases.turn_attempt_no = paired_turn_attempts.attempt_no + ORDER BY paired_task_execution_leases.updated_at DESC, + paired_task_execution_leases.claimed_at DESC + LIMIT 1 + ), + ( + SELECT paired_task_execution_leases.claimed_run_id + FROM paired_task_execution_leases + WHERE paired_task_execution_leases.turn_id = paired_turn_attempts.turn_id + AND paired_task_execution_leases.turn_attempt_no IS NULL + ORDER BY paired_task_execution_leases.updated_at DESC, + paired_task_execution_leases.claimed_at DESC + LIMIT 1 + ) + )` + : `( + SELECT paired_task_execution_leases.claimed_run_id + FROM paired_task_execution_leases + WHERE paired_task_execution_leases.turn_id = paired_turn_attempts.turn_id + ORDER BY paired_task_execution_leases.updated_at DESC, + paired_task_execution_leases.claimed_at DESC + LIMIT 1 + )` + }` + } + ELSE NULL + END`; + + database.transaction(() => { + database.exec(` + CREATE TABLE paired_turn_attempts_new ( + attempt_id TEXT NOT NULL PRIMARY KEY, + parent_attempt_id TEXT, + parent_handoff_id INTEGER, + continuation_handoff_id INTEGER, + turn_id TEXT NOT NULL, + attempt_no INTEGER NOT NULL, + task_id TEXT NOT NULL, + task_updated_at TEXT NOT NULL, + role TEXT NOT NULL, + intent_kind TEXT NOT NULL, + state TEXT NOT NULL, + executor_service_id TEXT, + executor_agent_type TEXT, + active_run_id TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + completed_at TEXT, + last_error TEXT, + UNIQUE (turn_id, attempt_no), + FOREIGN KEY (parent_attempt_id) + REFERENCES paired_turn_attempts_new(attempt_id) + ON DELETE CASCADE, + FOREIGN KEY (parent_handoff_id) + REFERENCES service_handoffs(id) + ON DELETE SET NULL, + FOREIGN KEY (continuation_handoff_id) + REFERENCES service_handoffs(id) + ON DELETE SET NULL, + FOREIGN KEY (turn_id) REFERENCES paired_turns(turn_id) ON DELETE CASCADE, + CHECK (role IN ('owner', 'reviewer', 'arbiter')), + CHECK ( + intent_kind IN ( + 'owner-turn', + 'reviewer-turn', + 'arbiter-turn', + 'owner-follow-up', + 'finalize-owner-turn' + ) + ), + CHECK ( + state IN ( + 'running', + 'delegated', + 'completed', + 'failed', + 'cancelled' + ) + ), + CHECK (executor_agent_type IN ('claude-code', 'codex') OR executor_agent_type IS NULL) + ); + INSERT INTO paired_turn_attempts_new ( + attempt_id, + parent_attempt_id, + parent_handoff_id, + continuation_handoff_id, + turn_id, + attempt_no, + task_id, + task_updated_at, + role, + intent_kind, + state, + executor_service_id, + executor_agent_type, + active_run_id, + created_at, + updated_at, + completed_at, + last_error + ) + SELECT + COALESCE(attempt_id, ${buildPairedTurnAttemptIdSql('turn_id', 'attempt_no')}), + ${parentAttemptIdSelectSql}, + ${parentHandoffIdSelectSql}, + ${continuationHandoffIdSelectSql}, + turn_id, + attempt_no, + task_id, + task_updated_at, + role, + intent_kind, + state, + executor_service_id, + executor_agent_type, + ${activeRunIdSelectSql}, + created_at, + updated_at, + completed_at, + last_error + FROM paired_turn_attempts + ORDER BY turn_id ASC, attempt_no ASC; + DROP TABLE paired_turn_attempts; + ALTER TABLE paired_turn_attempts_new RENAME TO paired_turn_attempts; + CREATE INDEX IF NOT EXISTS idx_paired_turn_attempts_attempt_id + ON paired_turn_attempts(attempt_id); + CREATE INDEX IF NOT EXISTS idx_paired_turn_attempts_parent_attempt_id + ON paired_turn_attempts(parent_attempt_id); + CREATE INDEX IF NOT EXISTS idx_paired_turn_attempts_parent_handoff_id + ON paired_turn_attempts(parent_handoff_id); + CREATE INDEX IF NOT EXISTS idx_paired_turn_attempts_continuation_handoff_id + ON paired_turn_attempts(continuation_handoff_id); + CREATE INDEX IF NOT EXISTS idx_paired_turn_attempts_turn + ON paired_turn_attempts(turn_id, attempt_no); + CREATE INDEX IF NOT EXISTS idx_paired_turn_attempts_task + ON paired_turn_attempts(task_id, task_updated_at, attempt_no); + `); + })(); +} + +function rebuildPairedTurnsWithoutLegacyScratchColumns( + database: Database, +): void { + if ( + !tableHasColumn(database, 'paired_turns', 'next_parent_handoff_id') && + !tableHasColumn(database, 'paired_turns', 'active_run_id') && + !tableHasColumn(database, 'paired_turns', 'state') && + !tableHasColumn(database, 'paired_turns', 'executor_service_id') && + !tableHasColumn(database, 'paired_turns', 'executor_agent_type') && + !tableHasColumn(database, 'paired_turns', 'attempt_no') && + !tableHasColumn(database, 'paired_turns', 'completed_at') && + !tableHasColumn(database, 'paired_turns', 'last_error') + ) { + return; + } + + database.transaction(() => { + database.exec(` + CREATE TABLE paired_turns_new ( + turn_id TEXT PRIMARY KEY, + task_id TEXT NOT NULL, + task_updated_at TEXT NOT NULL, + role TEXT NOT NULL, + intent_kind TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + CHECK (role IN ('owner', 'reviewer', 'arbiter')), + CHECK ( + intent_kind IN ( + 'owner-turn', + 'reviewer-turn', + 'arbiter-turn', + 'owner-follow-up', + 'finalize-owner-turn' + ) + ) + ); + INSERT INTO paired_turns_new ( + turn_id, + task_id, + task_updated_at, + role, + intent_kind, + created_at, + updated_at + ) + SELECT + turn_id, + task_id, + task_updated_at, + role, + intent_kind, + created_at, + updated_at + FROM paired_turns + ORDER BY created_at ASC, turn_id ASC; + DROP TABLE paired_turns; + ALTER TABLE paired_turns_new RENAME TO paired_turns; + CREATE INDEX IF NOT EXISTS idx_paired_turns_task + ON paired_turns(task_id, task_updated_at, updated_at); + `); + })(); +} + +function rebuildPairedTurnReservationsWithForeignKeys( + database: Database, +): void { + if ( + !tableHasColumn(database, 'paired_turn_reservations', 'turn_attempt_no') || + (tableHasForeignKey(database, { + tableName: 'paired_turn_reservations', + referencedTable: 'paired_turn_attempts', + fromColumns: ['turn_id', 'turn_attempt_no'], + toColumns: ['turn_id', 'attempt_no'], + onDelete: 'CASCADE', + }) && + tableHasColumn(database, 'paired_turn_reservations', 'turn_attempt_id') && + tableHasForeignKey(database, { + tableName: 'paired_turn_reservations', + referencedTable: 'paired_turn_attempts', + fromColumns: ['turn_attempt_id'], + toColumns: ['attempt_id'], + onDelete: 'CASCADE', + })) + ) { + return; + } + + database.transaction(() => { + database.exec(` + CREATE TABLE paired_turn_reservations_new ( + chat_jid TEXT NOT NULL, + task_id TEXT NOT NULL, + task_status TEXT NOT NULL, + round_trip_count INTEGER NOT NULL DEFAULT 0, + task_updated_at TEXT NOT NULL, + turn_id TEXT NOT NULL, + turn_attempt_id TEXT, + turn_attempt_no INTEGER, + turn_role TEXT NOT NULL, + intent_kind TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + scheduled_run_id TEXT, + consumed_run_id TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + consumed_at TEXT, + PRIMARY KEY (chat_jid, task_id, task_updated_at, intent_kind), + FOREIGN KEY (turn_attempt_id) + REFERENCES paired_turn_attempts(attempt_id) + ON DELETE CASCADE, + FOREIGN KEY (turn_id, turn_attempt_no) + REFERENCES paired_turn_attempts(turn_id, attempt_no) + ON DELETE CASCADE, + CHECK (status IN ('pending', 'completed')), + CHECK (turn_role IN ('owner', 'reviewer', 'arbiter')), + CHECK ( + intent_kind IN ( + 'owner-turn', + 'reviewer-turn', + 'arbiter-turn', + 'owner-follow-up', + 'finalize-owner-turn' + ) + ) + ); + INSERT INTO paired_turn_reservations_new ( + chat_jid, + task_id, + task_status, + round_trip_count, + task_updated_at, + turn_id, + turn_attempt_id, + turn_attempt_no, + turn_role, + intent_kind, + status, + scheduled_run_id, + consumed_run_id, + created_at, + updated_at, + consumed_at + ) + SELECT + chat_jid, + task_id, + task_status, + round_trip_count, + task_updated_at, + turn_id, + COALESCE( + turn_attempt_id, + CASE + WHEN turn_attempt_no IS NOT NULL + THEN ${buildPairedTurnAttemptIdSql('turn_id', 'turn_attempt_no')} + ELSE NULL + END + ), + turn_attempt_no, + turn_role, + intent_kind, + status, + scheduled_run_id, + consumed_run_id, + created_at, + updated_at, + consumed_at + FROM paired_turn_reservations; + DROP TABLE paired_turn_reservations; + ALTER TABLE paired_turn_reservations_new RENAME TO paired_turn_reservations; + CREATE INDEX IF NOT EXISTS idx_paired_turn_reservations_task + ON paired_turn_reservations(task_id, task_updated_at, status); + `); + })(); +} + +function rebuildPairedTaskExecutionLeasesWithForeignKeys( + database: Database, +): void { + if ( + !tableHasColumn( + database, + 'paired_task_execution_leases', + 'turn_attempt_no', + ) || + (tableHasForeignKey(database, { + tableName: 'paired_task_execution_leases', + referencedTable: 'paired_turn_attempts', + fromColumns: ['turn_id', 'turn_attempt_no'], + toColumns: ['turn_id', 'attempt_no'], + onDelete: 'CASCADE', + }) && + tableHasColumn( + database, + 'paired_task_execution_leases', + 'turn_attempt_id', + ) && + tableHasForeignKey(database, { + tableName: 'paired_task_execution_leases', + referencedTable: 'paired_turn_attempts', + fromColumns: ['turn_attempt_id'], + toColumns: ['attempt_id'], + onDelete: 'CASCADE', + })) + ) { + return; + } + + database.transaction(() => { + database.exec(` + CREATE TABLE paired_task_execution_leases_new ( + task_id TEXT PRIMARY KEY, + chat_jid TEXT NOT NULL, + role TEXT NOT NULL, + turn_id TEXT NOT NULL, + turn_attempt_id TEXT, + turn_attempt_no INTEGER, + intent_kind TEXT NOT NULL, + claimed_run_id TEXT NOT NULL, + claimed_service_id TEXT NOT NULL, + task_status TEXT NOT NULL, + task_updated_at TEXT NOT NULL, + claimed_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + FOREIGN KEY (turn_attempt_id) + REFERENCES paired_turn_attempts(attempt_id) + ON DELETE CASCADE, + FOREIGN KEY (turn_id, turn_attempt_no) + REFERENCES paired_turn_attempts(turn_id, attempt_no) + ON DELETE CASCADE, + CHECK (role IN ('owner', 'reviewer', 'arbiter')), + CHECK ( + intent_kind IN ( + 'owner-turn', + 'reviewer-turn', + 'arbiter-turn', + 'owner-follow-up', + 'finalize-owner-turn' + ) + ) + ); + INSERT INTO paired_task_execution_leases_new ( + task_id, + chat_jid, + role, + turn_id, + turn_attempt_id, + turn_attempt_no, + intent_kind, + claimed_run_id, + claimed_service_id, + task_status, + task_updated_at, + claimed_at, + updated_at, + expires_at + ) + SELECT + task_id, + chat_jid, + role, + turn_id, + COALESCE( + turn_attempt_id, + CASE + WHEN turn_attempt_no IS NOT NULL + THEN ${buildPairedTurnAttemptIdSql('turn_id', 'turn_attempt_no')} + ELSE NULL + END + ), + turn_attempt_no, + intent_kind, + claimed_run_id, + claimed_service_id, + task_status, + task_updated_at, + claimed_at, + updated_at, + expires_at + FROM paired_task_execution_leases; + DROP TABLE paired_task_execution_leases; + ALTER TABLE paired_task_execution_leases_new RENAME TO paired_task_execution_leases; + CREATE INDEX IF NOT EXISTS idx_paired_task_execution_leases_expires_at + ON paired_task_execution_leases(expires_at); + `); + })(); +} + +function rebuildServiceHandoffsWithForeignKeys(database: Database): void { + if ( + !tableHasColumn(database, 'service_handoffs', 'turn_attempt_no') || + (tableHasForeignKey(database, { + tableName: 'service_handoffs', + referencedTable: 'paired_turn_attempts', + fromColumns: ['turn_id', 'turn_attempt_no'], + toColumns: ['turn_id', 'attempt_no'], + onDelete: 'CASCADE', + }) && + tableHasColumn(database, 'service_handoffs', 'turn_attempt_id') && + tableHasForeignKey(database, { + tableName: 'service_handoffs', + referencedTable: 'paired_turn_attempts', + fromColumns: ['turn_attempt_id'], + toColumns: ['attempt_id'], + onDelete: 'CASCADE', + })) + ) { + return; + } + + database.transaction(() => { + database.exec(` + CREATE TABLE service_handoffs_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + chat_jid TEXT NOT NULL, + group_folder TEXT NOT NULL, + source_service_id TEXT NOT NULL, + target_service_id TEXT NOT NULL, + paired_task_id TEXT, + paired_task_updated_at TEXT, + turn_id TEXT, + turn_attempt_id TEXT, + turn_attempt_no INTEGER, + turn_intent_kind TEXT, + turn_role TEXT, + source_role TEXT, + source_agent_type TEXT, + target_role TEXT, + target_agent_type TEXT NOT NULL, + prompt TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + start_seq INTEGER, + end_seq INTEGER, + reason TEXT, + intended_role TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + claimed_at TEXT, + completed_at TEXT, + last_error TEXT, + FOREIGN KEY (turn_attempt_id) + REFERENCES paired_turn_attempts(attempt_id) + ON DELETE CASCADE, + FOREIGN KEY (turn_id, turn_attempt_no) + REFERENCES paired_turn_attempts(turn_id, attempt_no) + ON DELETE CASCADE, + CHECK (status IN ('pending', 'claimed', 'completed', 'failed')), + CHECK (intended_role IN ('owner', 'reviewer', 'arbiter') OR intended_role IS NULL), + CHECK ( + turn_intent_kind IN ( + 'owner-turn', + 'reviewer-turn', + 'arbiter-turn', + 'owner-follow-up', + 'finalize-owner-turn' + ) OR turn_intent_kind IS NULL + ), + CHECK (turn_role IN ('owner', 'reviewer', 'arbiter') OR turn_role IS NULL), + CHECK (source_role IN ('owner', 'reviewer', 'arbiter') OR source_role IS NULL), + CHECK (target_role IN ('owner', 'reviewer', 'arbiter') OR target_role IS NULL) + ); + INSERT INTO service_handoffs_new ( + id, + chat_jid, + group_folder, + source_service_id, + target_service_id, + paired_task_id, + paired_task_updated_at, + turn_id, + turn_attempt_id, + turn_attempt_no, + turn_intent_kind, + turn_role, + 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_service_id, + target_service_id, + paired_task_id, + paired_task_updated_at, + turn_id, + COALESCE( + turn_attempt_id, + CASE + WHEN turn_attempt_no IS NOT NULL + THEN ${buildPairedTurnAttemptIdSql('turn_id', 'turn_attempt_no')} + ELSE NULL + END + ), + turn_attempt_no, + turn_intent_kind, + turn_role, + 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); + `); + })(); +} + +function rebuildPairedTurnAttemptForeignKeyTables(database: Database): void { + rebuildPairedTurnAttemptsWithForeignKeys(database); + rebuildPairedTurnReservationsWithForeignKeys(database); + rebuildPairedTaskExecutionLeasesWithForeignKeys(database); + rebuildServiceHandoffsWithForeignKeys(database); +} + +function dropPairedTurnAttemptProvenanceConstraints(database: Database): void { + database.exec(` + DROP TRIGGER IF EXISTS paired_turn_attempts_validate_insert; + DROP TRIGGER IF EXISTS paired_turn_attempts_validate_update; + DROP TRIGGER IF EXISTS paired_turn_reservations_validate_attempt_insert; + DROP TRIGGER IF EXISTS paired_turn_reservations_validate_attempt_update; + DROP TRIGGER IF EXISTS paired_task_execution_leases_validate_attempt_insert; + DROP TRIGGER IF EXISTS paired_task_execution_leases_validate_attempt_update; + DROP TRIGGER IF EXISTS service_handoffs_validate_attempt_insert; + DROP TRIGGER IF EXISTS service_handoffs_validate_attempt_update; + `); +} + +function applyPairedTurnAttemptProvenanceConstraints(database: Database): void { + database.exec(` + CREATE TRIGGER IF NOT EXISTS paired_turn_attempts_validate_insert + BEFORE INSERT ON paired_turn_attempts + BEGIN + SELECT RAISE(ABORT, 'paired_turn_attempts attempt_id must match turn_id/attempt_no') + WHERE NEW.attempt_id IS NULL + OR NEW.attempt_id != ${buildPairedTurnAttemptIdSql('NEW.turn_id', 'NEW.attempt_no')}; + SELECT RAISE(ABORT, 'paired_turn_attempts attempt 1 cannot declare parent_attempt_id') + WHERE NEW.attempt_no <= 1 + AND NEW.parent_attempt_id IS NOT NULL; + SELECT RAISE(ABORT, 'paired_turn_attempts must preserve contiguous parent lineage') + WHERE NEW.attempt_no > 1 + AND NOT EXISTS ( + SELECT 1 + FROM paired_turn_attempts previous_attempt + WHERE previous_attempt.turn_id = NEW.turn_id + AND previous_attempt.attempt_no = NEW.attempt_no - 1 + ); + SELECT RAISE(ABORT, 'paired_turn_attempts must keep parent_attempt_id lineage') + WHERE NEW.attempt_no > 1 + AND ( + NEW.parent_attempt_id IS NULL + OR NEW.parent_attempt_id != ${buildPairedTurnAttemptParentIdSql('NEW.turn_id', 'NEW.attempt_no')} + ); + SELECT RAISE(ABORT, 'paired_turn_attempts parent_attempt_id must point to the previous attempt of the same turn') + WHERE NEW.parent_attempt_id IS NOT NULL + AND NOT EXISTS ( + SELECT 1 + FROM paired_turn_attempts previous_attempt + WHERE COALESCE( + previous_attempt.attempt_id, + ${buildPairedTurnAttemptIdSql( + 'previous_attempt.turn_id', + 'previous_attempt.attempt_no', + )} + ) = NEW.parent_attempt_id + AND previous_attempt.turn_id = NEW.turn_id + AND previous_attempt.attempt_no = NEW.attempt_no - 1 + ); + SELECT RAISE(ABORT, 'paired_turn_attempts attempt 1 cannot declare parent_handoff_id') + WHERE NEW.attempt_no <= 1 + AND NEW.parent_handoff_id IS NOT NULL; + SELECT RAISE(ABORT, 'paired_turn_attempts parent_handoff_id must reference the previous attempt handoff of the same turn') + WHERE NEW.parent_handoff_id IS NOT NULL + AND NOT ${buildPairedTurnAttemptParentHandoffMatchSql({ + parentHandoffIdExpr: 'NEW.parent_handoff_id', + turnIdExpr: 'NEW.turn_id', + parentAttemptIdExpr: 'NEW.parent_attempt_id', + attemptNoExpr: 'NEW.attempt_no', + })}; + SELECT RAISE(ABORT, 'paired_turn_attempts continuation_handoff_id must reference a handoff of the same attempt') + WHERE NEW.continuation_handoff_id IS NOT NULL + AND NOT ${buildPairedTurnAttemptContinuationHandoffMatchSql({ + continuationHandoffIdExpr: 'NEW.continuation_handoff_id', + turnIdExpr: 'NEW.turn_id', + attemptIdExpr: 'NEW.attempt_id', + attemptNoExpr: 'NEW.attempt_no', + })}; + SELECT RAISE(ABORT, 'paired_turn_attempts running attempts must declare active_run_id') + WHERE NEW.state = 'running' + AND ( + NEW.active_run_id IS NULL + OR TRIM(NEW.active_run_id) = '' + ); + SELECT RAISE(ABORT, 'paired_turn_attempts only running attempts may declare active_run_id') + WHERE NEW.state != 'running' + AND NEW.active_run_id IS NOT NULL; + SELECT RAISE(ABORT, 'paired_turn_attempts must reference a matching paired_turns row') + WHERE NOT EXISTS ( + SELECT 1 + FROM paired_turns + WHERE paired_turns.turn_id = NEW.turn_id + AND paired_turns.task_id = NEW.task_id + AND paired_turns.task_updated_at = NEW.task_updated_at + AND paired_turns.role = NEW.role + AND paired_turns.intent_kind = NEW.intent_kind + ); + END; + CREATE TRIGGER IF NOT EXISTS paired_turn_attempts_validate_update + BEFORE UPDATE OF attempt_id, parent_attempt_id, parent_handoff_id, continuation_handoff_id, turn_id, attempt_no, task_id, task_updated_at, role, intent_kind, state, executor_service_id, executor_agent_type, active_run_id + ON paired_turn_attempts + BEGIN + SELECT RAISE(ABORT, 'paired_turn_attempts attempt_id must match turn_id/attempt_no') + WHERE NEW.attempt_id IS NULL + OR NEW.attempt_id != ${buildPairedTurnAttemptIdSql('NEW.turn_id', 'NEW.attempt_no')}; + SELECT RAISE(ABORT, 'paired_turn_attempts attempt 1 cannot declare parent_attempt_id') + WHERE NEW.attempt_no <= 1 + AND NEW.parent_attempt_id IS NOT NULL; + SELECT RAISE(ABORT, 'paired_turn_attempts must preserve contiguous parent lineage') + WHERE NEW.attempt_no > 1 + AND NOT EXISTS ( + SELECT 1 + FROM paired_turn_attempts previous_attempt + WHERE previous_attempt.turn_id = NEW.turn_id + AND previous_attempt.attempt_no = NEW.attempt_no - 1 + ); + SELECT RAISE(ABORT, 'paired_turn_attempts must keep parent_attempt_id lineage') + WHERE NEW.attempt_no > 1 + AND ( + NEW.parent_attempt_id IS NULL + OR NEW.parent_attempt_id != ${buildPairedTurnAttemptParentIdSql('NEW.turn_id', 'NEW.attempt_no')} + ); + SELECT RAISE(ABORT, 'paired_turn_attempts parent_attempt_id must point to the previous attempt of the same turn') + WHERE NEW.parent_attempt_id IS NOT NULL + AND NOT EXISTS ( + SELECT 1 + FROM paired_turn_attempts previous_attempt + WHERE COALESCE( + previous_attempt.attempt_id, + ${buildPairedTurnAttemptIdSql( + 'previous_attempt.turn_id', + 'previous_attempt.attempt_no', + )} + ) = NEW.parent_attempt_id + AND previous_attempt.turn_id = NEW.turn_id + AND previous_attempt.attempt_no = NEW.attempt_no - 1 + ); + SELECT RAISE(ABORT, 'paired_turn_attempts attempt 1 cannot declare parent_handoff_id') + WHERE NEW.attempt_no <= 1 + AND NEW.parent_handoff_id IS NOT NULL; + SELECT RAISE(ABORT, 'paired_turn_attempts parent_handoff_id must reference the previous attempt handoff of the same turn') + WHERE NEW.parent_handoff_id IS NOT NULL + AND NOT ${buildPairedTurnAttemptParentHandoffMatchSql({ + parentHandoffIdExpr: 'NEW.parent_handoff_id', + turnIdExpr: 'NEW.turn_id', + parentAttemptIdExpr: 'NEW.parent_attempt_id', + attemptNoExpr: 'NEW.attempt_no', + })}; + SELECT RAISE(ABORT, 'paired_turn_attempts continuation_handoff_id must reference a handoff of the same attempt') + WHERE NEW.continuation_handoff_id IS NOT NULL + AND NOT ${buildPairedTurnAttemptContinuationHandoffMatchSql({ + continuationHandoffIdExpr: 'NEW.continuation_handoff_id', + turnIdExpr: 'NEW.turn_id', + attemptIdExpr: 'NEW.attempt_id', + attemptNoExpr: 'NEW.attempt_no', + })}; + SELECT RAISE(ABORT, 'paired_turn_attempts running attempts must declare active_run_id') + WHERE NEW.state = 'running' + AND ( + NEW.active_run_id IS NULL + OR TRIM(NEW.active_run_id) = '' + ); + SELECT RAISE(ABORT, 'paired_turn_attempts only running attempts may declare active_run_id') + WHERE NEW.state != 'running' + AND NEW.active_run_id IS NOT NULL; + SELECT RAISE(ABORT, 'paired_turn_attempts must reference a matching paired_turns row') + WHERE NOT EXISTS ( + SELECT 1 + FROM paired_turns + WHERE paired_turns.turn_id = NEW.turn_id + AND paired_turns.task_id = NEW.task_id + AND paired_turns.task_updated_at = NEW.task_updated_at + AND paired_turns.role = NEW.role + AND paired_turns.intent_kind = NEW.intent_kind + ); + END; + `); + + if (tableHasColumn(database, 'paired_turn_reservations', 'turn_attempt_no')) { + database.exec(` + CREATE INDEX IF NOT EXISTS idx_paired_turn_reservations_turn_attempt + ON paired_turn_reservations(turn_id, turn_attempt_no); + CREATE INDEX IF NOT EXISTS idx_paired_turn_reservations_turn_attempt_id + ON paired_turn_reservations(turn_attempt_id); + CREATE TRIGGER IF NOT EXISTS paired_turn_reservations_validate_attempt_insert + BEFORE INSERT ON paired_turn_reservations + BEGIN + SELECT RAISE(ABORT, 'paired_turn_reservations.turn_attempt_no requires turn_id') + WHERE NEW.turn_attempt_no IS NOT NULL + AND NEW.turn_id IS NULL; + SELECT RAISE(ABORT, 'paired_turn_reservations.turn_attempt provenance must keep attempt_id and attempt_no in sync') + WHERE (NEW.turn_attempt_id IS NULL) != (NEW.turn_attempt_no IS NULL); + SELECT RAISE(ABORT, 'paired_turn_reservations turn_attempt_no must reference a matching paired_turn_attempts row') + WHERE NEW.turn_attempt_id IS NOT NULL + AND NOT EXISTS ( + SELECT 1 + FROM paired_turn_attempts + WHERE paired_turn_attempts.attempt_id = NEW.turn_attempt_id + AND paired_turn_attempts.turn_id = NEW.turn_id + AND paired_turn_attempts.attempt_no = NEW.turn_attempt_no + AND paired_turn_attempts.task_id = NEW.task_id + AND paired_turn_attempts.task_updated_at = NEW.task_updated_at + AND paired_turn_attempts.role = NEW.turn_role + AND paired_turn_attempts.intent_kind = NEW.intent_kind + ); + END; + CREATE TRIGGER IF NOT EXISTS paired_turn_reservations_validate_attempt_update + BEFORE UPDATE OF turn_id, turn_attempt_id, turn_attempt_no, task_id, task_updated_at, turn_role, intent_kind + ON paired_turn_reservations + BEGIN + SELECT RAISE(ABORT, 'paired_turn_reservations.turn_attempt_no requires turn_id') + WHERE NEW.turn_attempt_no IS NOT NULL + AND NEW.turn_id IS NULL; + SELECT RAISE(ABORT, 'paired_turn_reservations.turn_attempt provenance must keep attempt_id and attempt_no in sync') + WHERE (NEW.turn_attempt_id IS NULL) != (NEW.turn_attempt_no IS NULL); + SELECT RAISE(ABORT, 'paired_turn_reservations turn_attempt_no must reference a matching paired_turn_attempts row') + WHERE NEW.turn_attempt_id IS NOT NULL + AND NOT EXISTS ( + SELECT 1 + FROM paired_turn_attempts + WHERE paired_turn_attempts.attempt_id = NEW.turn_attempt_id + AND paired_turn_attempts.turn_id = NEW.turn_id + AND paired_turn_attempts.attempt_no = NEW.turn_attempt_no + AND paired_turn_attempts.task_id = NEW.task_id + AND paired_turn_attempts.task_updated_at = NEW.task_updated_at + AND paired_turn_attempts.role = NEW.turn_role + AND paired_turn_attempts.intent_kind = NEW.intent_kind + ); + END; + `); + } + + if ( + tableHasColumn(database, 'paired_task_execution_leases', 'turn_attempt_no') + ) { + database.exec(` + CREATE INDEX IF NOT EXISTS idx_paired_task_execution_leases_turn_attempt + ON paired_task_execution_leases(turn_id, turn_attempt_no); + CREATE INDEX IF NOT EXISTS idx_paired_task_execution_leases_turn_attempt_id + ON paired_task_execution_leases(turn_attempt_id); + CREATE TRIGGER IF NOT EXISTS paired_task_execution_leases_validate_attempt_insert + BEFORE INSERT ON paired_task_execution_leases + BEGIN + SELECT RAISE(ABORT, 'paired_task_execution_leases.turn_attempt_no requires turn_id') + WHERE NEW.turn_attempt_no IS NOT NULL + AND NEW.turn_id IS NULL; + SELECT RAISE(ABORT, 'paired_task_execution_leases turn_attempt provenance must keep attempt_id and attempt_no in sync') + WHERE (NEW.turn_attempt_id IS NULL) != (NEW.turn_attempt_no IS NULL); + SELECT RAISE(ABORT, 'paired_task_execution_leases turn_attempt_no must reference a matching paired_turn_attempts row') + WHERE NEW.turn_attempt_id IS NOT NULL + AND NOT EXISTS ( + SELECT 1 + FROM paired_turn_attempts + WHERE paired_turn_attempts.attempt_id = NEW.turn_attempt_id + AND paired_turn_attempts.turn_id = NEW.turn_id + AND paired_turn_attempts.attempt_no = NEW.turn_attempt_no + AND paired_turn_attempts.task_id = NEW.task_id + AND paired_turn_attempts.task_updated_at = NEW.task_updated_at + AND paired_turn_attempts.role = NEW.role + AND paired_turn_attempts.intent_kind = NEW.intent_kind + ); + END; + CREATE TRIGGER IF NOT EXISTS paired_task_execution_leases_validate_attempt_update + BEFORE UPDATE OF turn_id, turn_attempt_id, turn_attempt_no, task_id, task_updated_at, role, intent_kind + ON paired_task_execution_leases + BEGIN + SELECT RAISE(ABORT, 'paired_task_execution_leases.turn_attempt_no requires turn_id') + WHERE NEW.turn_attempt_no IS NOT NULL + AND NEW.turn_id IS NULL; + SELECT RAISE(ABORT, 'paired_task_execution_leases turn_attempt provenance must keep attempt_id and attempt_no in sync') + WHERE (NEW.turn_attempt_id IS NULL) != (NEW.turn_attempt_no IS NULL); + SELECT RAISE(ABORT, 'paired_task_execution_leases turn_attempt_no must reference a matching paired_turn_attempts row') + WHERE NEW.turn_attempt_id IS NOT NULL + AND NOT EXISTS ( + SELECT 1 + FROM paired_turn_attempts + WHERE paired_turn_attempts.attempt_id = NEW.turn_attempt_id + AND paired_turn_attempts.turn_id = NEW.turn_id + AND paired_turn_attempts.attempt_no = NEW.turn_attempt_no + AND paired_turn_attempts.task_id = NEW.task_id + AND paired_turn_attempts.task_updated_at = NEW.task_updated_at + AND paired_turn_attempts.role = NEW.role + AND paired_turn_attempts.intent_kind = NEW.intent_kind + ); + END; + `); + } + + if ( + tableHasColumn(database, 'service_handoffs', 'turn_attempt_no') && + tableHasColumn(database, 'service_handoffs', 'paired_task_id') && + tableHasColumn(database, 'service_handoffs', 'paired_task_updated_at') && + tableHasColumn(database, 'service_handoffs', 'turn_role') && + tableHasColumn(database, 'service_handoffs', 'turn_intent_kind') + ) { + database.exec(` + CREATE INDEX IF NOT EXISTS idx_service_handoffs_turn_attempt + ON service_handoffs(turn_id, turn_attempt_no); + CREATE INDEX IF NOT EXISTS idx_service_handoffs_turn_attempt_id + ON service_handoffs(turn_attempt_id); + CREATE TRIGGER IF NOT EXISTS service_handoffs_validate_attempt_insert + BEFORE INSERT ON service_handoffs + BEGIN + SELECT RAISE(ABORT, 'service_handoffs.turn_attempt_no requires paired turn identity') + WHERE NEW.turn_attempt_no IS NOT NULL + AND ( + NEW.turn_id IS NULL + OR NEW.paired_task_id IS NULL + OR NEW.paired_task_updated_at IS NULL + OR NEW.turn_role IS NULL + OR NEW.turn_intent_kind IS NULL + ); + SELECT RAISE(ABORT, 'service_handoffs turn_attempt provenance must keep attempt_id and attempt_no in sync') + WHERE (NEW.turn_attempt_id IS NULL) != (NEW.turn_attempt_no IS NULL); + SELECT RAISE(ABORT, 'service_handoffs turn_attempt_no must reference a matching paired_turn_attempts row') + WHERE NEW.turn_attempt_id IS NOT NULL + AND NOT EXISTS ( + SELECT 1 + FROM paired_turn_attempts + WHERE paired_turn_attempts.attempt_id = NEW.turn_attempt_id + AND paired_turn_attempts.turn_id = NEW.turn_id + AND paired_turn_attempts.attempt_no = NEW.turn_attempt_no + AND paired_turn_attempts.task_id = NEW.paired_task_id + AND paired_turn_attempts.task_updated_at = NEW.paired_task_updated_at + AND paired_turn_attempts.role = NEW.turn_role + AND paired_turn_attempts.intent_kind = NEW.turn_intent_kind + ); + END; + CREATE TRIGGER IF NOT EXISTS service_handoffs_validate_attempt_update + BEFORE UPDATE OF turn_id, turn_attempt_id, turn_attempt_no, paired_task_id, paired_task_updated_at, turn_role, turn_intent_kind + ON service_handoffs + BEGIN + SELECT RAISE(ABORT, 'service_handoffs.turn_attempt_no requires paired turn identity') + WHERE NEW.turn_attempt_no IS NOT NULL + AND ( + NEW.turn_id IS NULL + OR NEW.paired_task_id IS NULL + OR NEW.paired_task_updated_at IS NULL + OR NEW.turn_role IS NULL + OR NEW.turn_intent_kind IS NULL + ); + SELECT RAISE(ABORT, 'service_handoffs turn_attempt provenance must keep attempt_id and attempt_no in sync') + WHERE (NEW.turn_attempt_id IS NULL) != (NEW.turn_attempt_no IS NULL); + SELECT RAISE(ABORT, 'service_handoffs turn_attempt_no must reference a matching paired_turn_attempts row') + WHERE NEW.turn_attempt_id IS NOT NULL + AND NOT EXISTS ( + SELECT 1 + FROM paired_turn_attempts + WHERE paired_turn_attempts.attempt_id = NEW.turn_attempt_id + AND paired_turn_attempts.turn_id = NEW.turn_id + AND paired_turn_attempts.attempt_no = NEW.turn_attempt_no + AND paired_turn_attempts.task_id = NEW.paired_task_id + AND paired_turn_attempts.task_updated_at = NEW.paired_task_updated_at + AND paired_turn_attempts.role = NEW.turn_role + AND paired_turn_attempts.intent_kind = NEW.turn_intent_kind + ); + END; + `); + } +} + interface StoredWorkItemServiceRow { id: number; agent_type?: string | null; @@ -149,36 +2191,7 @@ function normalizePairedRole( function inferLegacyExecutionLeaseServiceId( row: LegacyExecutionLeaseServiceRow, ): string | null { - switch (row.role) { - case 'owner': { - const ownerAgentType = normalizeStoredAgentType(row.owner_agent_type); - return ( - (row.owner_service_id - ? normalizeServiceId(row.owner_service_id) - : null) ?? resolveRoleServiceShadow('owner', ownerAgentType) - ); - } - case 'reviewer': { - const reviewerAgentType = normalizeStoredAgentType( - row.reviewer_agent_type, - ); - return ( - (row.reviewer_service_id - ? normalizeServiceId(row.reviewer_service_id) - : null) ?? resolveRoleServiceShadow('reviewer', reviewerAgentType) - ); - } - case 'arbiter': { - const arbiterAgentType = normalizeStoredAgentType(row.arbiter_agent_type); - return ( - (row.arbiter_service_id - ? normalizeServiceId(row.arbiter_service_id) - : null) ?? resolveRoleServiceShadow('arbiter', arbiterAgentType) - ); - } - default: - return null; - } + return inferExecutionLeaseServiceIdFromCanonicalMetadata(row); } function backfillLegacyExecutionLeaseServiceShadows(database: Database): void { @@ -272,12 +2285,14 @@ function backfillCanonicalPairedTaskServiceIds(database: Database): void { ` SELECT rowid, + id, chat_jid, group_folder, owner_service_id, reviewer_service_id, owner_agent_type, - reviewer_agent_type + reviewer_agent_type, + arbiter_agent_type FROM paired_tasks `, ) @@ -290,62 +2305,48 @@ function backfillCanonicalPairedTaskServiceIds(database: Database): void { ` UPDATE paired_tasks SET owner_service_id = ?, - reviewer_service_id = ? + reviewer_service_id = ?, + owner_agent_type = ?, + reviewer_agent_type = ?, + arbiter_agent_type = ? 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; + const { + ownerAgentType, + reviewerAgentType, + arbiterAgentType, + ownerServiceId, + reviewerServiceId, + } = canonicalizePairedTaskMetadata({ + id: row.id, + owner_service_id: row.owner_service_id, + reviewer_service_id: row.reviewer_service_id, + owner_agent_type: row.owner_agent_type, + reviewer_agent_type: row.reviewer_agent_type, + arbiter_agent_type: row.arbiter_agent_type, + }); if ( row.owner_service_id === ownerServiceId && - row.reviewer_service_id === reviewerServiceId + row.reviewer_service_id === reviewerServiceId && + row.owner_agent_type === ownerAgentType && + row.reviewer_agent_type === reviewerAgentType && + row.arbiter_agent_type === arbiterAgentType ) { continue; } - update.run(ownerServiceId, reviewerServiceId, row.rowid); + update.run( + ownerServiceId, + reviewerServiceId, + ownerAgentType, + reviewerAgentType, + arbiterAgentType, + row.rowid, + ); } }); @@ -382,65 +2383,40 @@ function backfillCanonicalChannelOwnerServiceIds(database: Database): void { UPDATE channel_owner SET owner_service_id = ?, reviewer_service_id = ?, - arbiter_service_id = ? + arbiter_service_id = ?, + owner_agent_type = ?, + reviewer_agent_type = ?, + arbiter_agent_type = ? 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)); + const { + ownerAgentType, + reviewerAgentType, + arbiterAgentType, + ownerServiceId, + reviewerServiceId, + arbiterServiceId, + } = canonicalizeChannelOwnerLeaseMetadata({ + chat_jid: row.chat_jid, + owner_service_id: row.owner_service_id, + reviewer_service_id: row.reviewer_service_id, + arbiter_service_id: row.arbiter_service_id, + owner_agent_type: row.owner_agent_type, + reviewer_agent_type: row.reviewer_agent_type, + arbiter_agent_type: row.arbiter_agent_type, + }); if ( row.owner_service_id === ownerServiceId && row.reviewer_service_id === reviewerServiceId && - row.arbiter_service_id === arbiterServiceId + row.arbiter_service_id === arbiterServiceId && + row.owner_agent_type === ownerAgentType && + row.reviewer_agent_type === reviewerAgentType && + row.arbiter_agent_type === arbiterAgentType ) { continue; } @@ -449,6 +2425,9 @@ function backfillCanonicalChannelOwnerServiceIds(database: Database): void { ownerServiceId, reviewerServiceId, arbiterServiceId, + ownerAgentType, + reviewerAgentType, + arbiterAgentType, row.rowid, ); } @@ -489,60 +2468,56 @@ function backfillCanonicalServiceHandoffServiceIds(database: Database): void { ` UPDATE service_handoffs SET source_service_id = ?, - target_service_id = ? + target_service_id = ?, + source_agent_type = ?, + target_agent_type = ?, + source_role = ?, + target_role = ? 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); + const { + sourceRole, + targetRole, + sourceAgentType, + targetAgentType, + sourceServiceId, + targetServiceId, + } = canonicalizeServiceHandoffMetadata({ + id: row.id, + chat_jid: row.chat_jid, + source_service_id: row.source_service_id, + target_service_id: row.target_service_id, + source_role: row.source_role, + target_role: row.target_role, + intended_role: row.intended_role, + source_agent_type: row.source_agent_type, + target_agent_type: row.target_agent_type, + }); if ( row.source_service_id === sourceServiceId && - row.target_service_id === targetServiceId + row.target_service_id === targetServiceId && + row.source_agent_type === (sourceAgentType ?? null) && + row.target_agent_type === targetAgentType && + row.source_role === sourceRole && + row.target_role === targetRole ) { continue; } - update.run(sourceServiceId, targetServiceId, row.id); + update.run( + sourceServiceId, + targetServiceId, + sourceAgentType, + targetAgentType, + sourceRole, + targetRole, + row.id, + ); } }, ); @@ -663,6 +2638,29 @@ export function applySchemaMigrations( database, `ALTER TABLE room_settings ADD COLUMN work_dir TEXT`, ); + tryExecMigration( + database, + `ALTER TABLE room_settings ADD COLUMN created_at TEXT`, + ); + if (tableHasColumn(database, 'room_settings', 'created_at')) { + database.exec(` + UPDATE room_settings + SET created_at = COALESCE(created_at, updated_at, CURRENT_TIMESTAMP) + `); + } + database.exec(` + CREATE TABLE IF NOT EXISTS room_role_overrides ( + chat_jid TEXT NOT NULL, + role TEXT NOT NULL, + agent_type TEXT NOT NULL, + agent_config_json TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (chat_jid, role), + CHECK (role IN ('owner', 'reviewer', 'arbiter')), + CHECK (agent_type IN ('claude-code', 'codex')) + ) + `); tryExecMigration( database, @@ -688,6 +2686,34 @@ export function applySchemaMigrations( database, `ALTER TABLE service_handoffs ADD COLUMN target_service_id TEXT`, ); + tryExecMigration( + database, + `ALTER TABLE service_handoffs ADD COLUMN paired_task_id TEXT`, + ); + tryExecMigration( + database, + `ALTER TABLE service_handoffs ADD COLUMN paired_task_updated_at TEXT`, + ); + tryExecMigration( + database, + `ALTER TABLE service_handoffs ADD COLUMN turn_id TEXT`, + ); + tryExecMigration( + database, + `ALTER TABLE service_handoffs ADD COLUMN turn_attempt_id TEXT`, + ); + tryExecMigration( + database, + `ALTER TABLE service_handoffs ADD COLUMN turn_attempt_no INTEGER`, + ); + tryExecMigration( + database, + `ALTER TABLE service_handoffs ADD COLUMN turn_intent_kind TEXT`, + ); + tryExecMigration( + database, + `ALTER TABLE service_handoffs ADD COLUMN turn_role TEXT`, + ); tryExecMigration( database, @@ -717,6 +2743,46 @@ export function applySchemaMigrations( database, `ALTER TABLE paired_task_execution_leases ADD COLUMN claimed_service_id TEXT`, ); + tryExecMigration( + database, + `ALTER TABLE paired_task_execution_leases ADD COLUMN turn_id TEXT`, + ); + tryExecMigration( + database, + `ALTER TABLE paired_task_execution_leases ADD COLUMN turn_attempt_id TEXT`, + ); + tryExecMigration( + database, + `ALTER TABLE paired_task_execution_leases ADD COLUMN turn_attempt_no INTEGER`, + ); + tryExecMigration( + database, + `ALTER TABLE paired_turn_attempts ADD COLUMN attempt_id TEXT`, + ); + tryExecMigration( + database, + `ALTER TABLE paired_turn_attempts ADD COLUMN parent_attempt_id TEXT`, + ); + tryExecMigration( + database, + `ALTER TABLE paired_turn_attempts ADD COLUMN parent_handoff_id INTEGER`, + ); + tryExecMigration( + database, + `ALTER TABLE paired_turn_attempts ADD COLUMN continuation_handoff_id INTEGER`, + ); + tryExecMigration( + database, + `ALTER TABLE paired_turn_attempts ADD COLUMN active_run_id TEXT`, + ); + database.exec(` + CREATE INDEX IF NOT EXISTS idx_paired_turn_attempts_parent_handoff_id + ON paired_turn_attempts(parent_handoff_id) + `); + database.exec(` + CREATE INDEX IF NOT EXISTS idx_paired_turn_attempts_continuation_handoff_id + ON paired_turn_attempts(continuation_handoff_id) + `); database.exec(` UPDATE paired_task_execution_leases SET expires_at = COALESCE( @@ -742,7 +2808,48 @@ export function applySchemaMigrations( database, `ALTER TABLE work_items ADD COLUMN service_id TEXT`, ); + tryExecMigration( + database, + `ALTER TABLE paired_turn_reservations ADD COLUMN turn_id TEXT`, + ); + tryExecMigration( + database, + `ALTER TABLE paired_turn_reservations ADD COLUMN turn_attempt_id TEXT`, + ); + tryExecMigration( + database, + `ALTER TABLE paired_turn_reservations ADD COLUMN turn_attempt_no INTEGER`, + ); + tryExecMigration( + database, + `ALTER TABLE paired_turn_reservations ADD COLUMN turn_role TEXT`, + ); + database.exec(` + UPDATE paired_turn_reservations + SET turn_id = COALESCE( + turn_id, + task_id || ':' || task_updated_at || ':' || intent_kind + ) + `); + database.exec(` + UPDATE paired_turn_reservations + SET turn_role = COALESCE( + turn_role, + CASE + WHEN intent_kind = 'reviewer-turn' THEN 'reviewer' + WHEN intent_kind = 'arbiter-turn' THEN 'arbiter' + ELSE 'owner' + END + ) + `); + database.exec(` + UPDATE paired_task_execution_leases + SET turn_id = COALESCE( + turn_id, + task_id || ':' || task_updated_at || ':' || intent_kind + ) + `); database.exec( `UPDATE service_handoffs SET target_role = COALESCE( @@ -763,6 +2870,47 @@ export function applySchemaMigrations( SET source_role = COALESCE(source_role, target_role, intended_role) WHERE source_role IS NULL`, ); + database.exec( + `UPDATE service_handoffs + SET turn_role = COALESCE(turn_role, target_role, intended_role) + WHERE turn_role IS NULL`, + ); + database.exec( + `UPDATE service_handoffs + SET turn_intent_kind = COALESCE( + turn_intent_kind, + CASE + WHEN turn_role = 'reviewer' THEN 'reviewer-turn' + WHEN turn_role = 'arbiter' THEN 'arbiter-turn' + ELSE turn_intent_kind + END + )`, + ); + database.exec( + `UPDATE service_handoffs + SET turn_id = COALESCE( + turn_id, + CASE + WHEN paired_task_id IS NOT NULL + AND paired_task_updated_at IS NOT NULL + AND turn_intent_kind IS NOT NULL + THEN paired_task_id || ':' || paired_task_updated_at || ':' || turn_intent_kind + ELSE turn_id + END + )`, + ); + + backfillPairedTurnAttemptsFromTurns(database); + backfillPairedTurnAttemptIds(database); + backfillPairedTurnAttemptParentIds(database); + backfillPairedTurnAttemptActiveRunIds(database); + backfillPairedTurnAttemptProvenance(database); + backfillPairedTurnAttemptEntityIds(database); + assertPairedTurnAttemptProvenanceIntegrity(database); + dropPairedTurnAttemptProvenanceConstraints(database); + rebuildPairedTurnsWithoutLegacyScratchColumns(database); + rebuildPairedTurnAttemptForeignKeyTables(database); + applyPairedTurnAttemptProvenanceConstraints(database); for (const column of [ 'owner_service_id', diff --git a/src/db/service-handoffs.ts b/src/db/service-handoffs.ts index 006cf3b..8cfaa5e 100644 --- a/src/db/service-handoffs.ts +++ b/src/db/service-handoffs.ts @@ -5,14 +5,21 @@ import { SERVICE_ID, SERVICE_SESSION_SCOPE, } from '../config.js'; -import { resolveRoleServiceShadow } from '../role-service-shadow.js'; +import { + buildPairedTurnIdentity, + type PairedTurnIdentity, +} from '../paired-turn-identity.js'; import { AgentType, PairedRoomRole } from '../types.js'; +import { setPairedTurnAttemptContinuationHandoffIdInDatabase } from './paired-turn-attempts.js'; +import { + failPairedTurnInDatabase, + markPairedTurnDelegatedInDatabase, +} from './paired-turns.js'; import { getLatestMessageSeqAtOrBeforeFromDatabase, normalizeSeqCursor, } from './messages.js'; -import { resolveStableRoomRoleAgentType } from './legacy-rebuilds.js'; -import { normalizeStoredAgentType } from './room-registration.js'; +import { canonicalizeServiceHandoffMetadata } from './canonical-role-metadata.js'; import { getRouterStateFromDatabase, setRouterStateInDatabase, @@ -22,6 +29,13 @@ export interface ServiceHandoff { id: number; chat_jid: string; group_folder: string; + paired_task_id?: string | null; + paired_task_updated_at?: string | null; + turn_id?: string | null; + turn_attempt_id?: string | null; + turn_attempt_no?: number | null; + turn_intent_kind?: PairedTurnIdentity['intentKind'] | null; + turn_role?: PairedRoomRole | null; source_service_id: string; target_service_id: string; source_role: PairedRoomRole | null; @@ -43,6 +57,12 @@ export interface ServiceHandoff { export interface CreateServiceHandoffInput { chat_jid: string; group_folder: string; + paired_task_id?: string | null; + paired_task_updated_at?: string | null; + turn_id?: string | null; + turn_attempt_id?: string | null; + turn_intent_kind?: PairedTurnIdentity['intentKind'] | null; + turn_role?: PairedRoomRole | null; source_service_id?: string; target_service_id?: string; source_role?: PairedRoomRole | null; @@ -76,46 +96,72 @@ interface StoredServiceHandoffRow extends Omit< target_agent_type: string; } +function hydrateStoredTurnIdentity( + row: Pick< + StoredServiceHandoffRow, + | 'paired_task_id' + | 'paired_task_updated_at' + | 'turn_id' + | 'turn_intent_kind' + | 'turn_role' + >, +): PairedTurnIdentity | null { + if ( + !row.paired_task_id || + !row.paired_task_updated_at || + !row.turn_intent_kind + ) { + return null; + } + + return buildPairedTurnIdentity({ + taskId: row.paired_task_id, + taskUpdatedAt: row.paired_task_updated_at, + intentKind: row.turn_intent_kind, + role: row.turn_role ?? undefined, + turnId: row.turn_id ?? undefined, + }); +} + function hydrateServiceHandoffRow( - database: Database, row: StoredServiceHandoffRow, ): ServiceHandoff { - 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); - 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) ?? - 'claude-code'; + const { + sourceRole, + targetRole, + sourceAgentType, + targetAgentType, + sourceServiceId, + targetServiceId, + } = canonicalizeServiceHandoffMetadata({ + id: row.id, + chat_jid: row.chat_jid, + source_service_id: row.source_service_id, + target_service_id: row.target_service_id, + source_role: row.source_role, + target_role: row.target_role, + intended_role: row.intended_role, + source_agent_type: row.source_agent_type, + target_agent_type: row.target_agent_type, + }); + const turnIdentity = hydrateStoredTurnIdentity(row); return { ...row, + paired_task_id: turnIdentity?.taskId ?? row.paired_task_id ?? null, + paired_task_updated_at: + turnIdentity?.taskUpdatedAt ?? row.paired_task_updated_at ?? null, + turn_id: turnIdentity?.turnId ?? row.turn_id ?? null, + turn_attempt_id: row.turn_attempt_id ?? null, + turn_attempt_no: row.turn_attempt_no ?? null, + turn_intent_kind: turnIdentity?.intentKind ?? row.turn_intent_kind ?? null, + turn_role: turnIdentity?.role ?? row.turn_role ?? null, + source_role: sourceRole, + target_role: targetRole, source_agent_type: sourceAgentType ?? null, target_agent_type: targetAgentType, - source_service_id: - row.source_service_id ?? - (row.source_role != null - ? (resolveRoleServiceShadow(row.source_role, sourceAgentType) ?? - SERVICE_SESSION_SCOPE) - : SERVICE_SESSION_SCOPE), - target_service_id: - row.target_service_id ?? - (row.target_role != null - ? (resolveRoleServiceShadow(row.target_role, targetAgentType) ?? - SERVICE_SESSION_SCOPE) - : SERVICE_SESSION_SCOPE), + source_service_id: sourceServiceId, + target_service_id: targetServiceId, }; } @@ -184,35 +230,66 @@ export function createServiceHandoffInDatabase( database: Database, input: CreateServiceHandoffInput, ): ServiceHandoff { - const sourceRole = input.source_role ?? input.intended_role ?? null; - const targetRole = input.target_role ?? input.intended_role ?? null; - const sourceAgentType = - normalizeStoredAgentType(input.source_agent_type) ?? - (sourceRole - ? resolveStableRoomRoleAgentType(database, { - chatJid: input.chat_jid, - groupFolder: input.group_folder, - role: sourceRole, + const turnIdentity = + input.paired_task_id && + input.paired_task_updated_at && + input.turn_intent_kind + ? buildPairedTurnIdentity({ + taskId: input.paired_task_id, + taskUpdatedAt: input.paired_task_updated_at, + intentKind: input.turn_intent_kind, + role: input.turn_role ?? undefined, + turnId: input.turn_id ?? undefined, }) - : 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; + : null; + const { + sourceRole, + targetRole, + sourceAgentType, + targetAgentType, + sourceServiceId, + targetServiceId, + } = canonicalizeServiceHandoffMetadata({ + id: 'new', + chat_jid: input.chat_jid, + source_service_id: input.source_service_id, + target_service_id: input.target_service_id, + source_role: input.source_role, + target_role: input.target_role, + intended_role: input.intended_role, + source_agent_type: input.source_agent_type, + target_agent_type: input.target_agent_type, + }); + + const currentAttempt = turnIdentity + ? markPairedTurnDelegatedInDatabase(database, { + turnIdentity, + executorServiceId: targetServiceId, + executorAgentType: targetAgentType, + }) + : undefined; + if (turnIdentity) { + if (!currentAttempt) { + throw new Error( + `paired_turns(${turnIdentity.turnId}) did not materialize a delegated attempt row`, + ); + } + } + const turnAttemptNo = currentAttempt?.attempt_no ?? null; + const turnAttemptId = currentAttempt?.attempt_id ?? null; database .prepare( `INSERT INTO service_handoffs ( chat_jid, group_folder, + paired_task_id, + paired_task_updated_at, + turn_id, + turn_attempt_id, + turn_attempt_no, + turn_intent_kind, + turn_role, source_service_id, target_service_id, source_role, @@ -224,17 +301,24 @@ export function createServiceHandoffInDatabase( end_seq, reason, intended_role - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) .run( input.chat_jid, input.group_folder, + turnIdentity?.taskId ?? null, + turnIdentity?.taskUpdatedAt ?? null, + turnIdentity?.turnId ?? null, + turnAttemptId, + turnAttemptNo, + turnIdentity?.intentKind ?? null, + turnIdentity?.role ?? null, sourceServiceId, targetServiceId, sourceRole, sourceAgentType ?? null, targetRole, - input.target_agent_type, + targetAgentType, input.prompt, input.start_seq ?? null, input.end_seq ?? null, @@ -245,8 +329,14 @@ export function createServiceHandoffInDatabase( const lastId = ( database.prepare('SELECT last_insert_rowid() as id').get() as { id: number } ).id; + if (turnIdentity && currentAttempt) { + setPairedTurnAttemptContinuationHandoffIdInDatabase(database, { + turnId: turnIdentity.turnId, + attemptNo: currentAttempt.attempt_no, + handoffId: lastId, + }); + } return hydrateServiceHandoffRow( - database, database .prepare('SELECT * FROM service_handoffs WHERE id = ?') .get(lastId) as StoredServiceHandoffRow, @@ -258,7 +348,7 @@ export function getPendingServiceHandoffsFromDatabase( targetServiceId: string = SERVICE_SESSION_SCOPE, ): ServiceHandoff[] { return getPendingServiceHandoffRows(database) - .map((row) => hydrateServiceHandoffRow(database, row)) + .map((row) => hydrateServiceHandoffRow(row)) .filter( (handoff) => normalizeServiceId(handoff.target_service_id) === @@ -270,7 +360,7 @@ export function getAllPendingServiceHandoffsFromDatabase( database: Database, ): ServiceHandoff[] { return getPendingServiceHandoffRows(database).map((row) => - hydrateServiceHandoffRow(database, row), + hydrateServiceHandoffRow(row), ); } @@ -312,15 +402,36 @@ export function failServiceHandoffInDatabase( id: number, error: string, ): void { - database - .prepare( - `UPDATE service_handoffs - SET status = 'failed', - completed_at = datetime('now'), - last_error = ? - WHERE id = ?`, - ) - .run(error, id); + database.transaction(() => { + const row = database + .prepare('SELECT * FROM service_handoffs WHERE id = ?') + .get(id) as StoredServiceHandoffRow | undefined; + let turnIdentity: PairedTurnIdentity | null = null; + if (row) { + try { + turnIdentity = hydrateStoredTurnIdentity(row); + } catch { + turnIdentity = null; + } + } + + database + .prepare( + `UPDATE service_handoffs + SET status = 'failed', + completed_at = datetime('now'), + last_error = ? + WHERE id = ?`, + ) + .run(error, id); + + if (turnIdentity) { + failPairedTurnInDatabase(database, { + turnIdentity, + error, + }); + } + })(); } export function completeServiceHandoffAndAdvanceTargetCursorInDatabase( diff --git a/src/index.ts b/src/index.ts index 32db1a8..b73b2f7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -168,11 +168,8 @@ const runtime = createMessageRuntime({ }); function loadState(): void { - lastTimestamp = normalizeStoredSeqCursor( - getRouterState('last_seq') || getRouterState('last_timestamp'), - ); - const agentTs = - getRouterState('last_agent_seq') || getRouterState('last_agent_timestamp'); + lastTimestamp = normalizeStoredSeqCursor(getRouterState('last_seq')); + const agentTs = getRouterState('last_agent_seq'); try { const parsed = agentTs ? (JSON.parse(agentTs) as Record) diff --git a/src/message-agent-executor-paired.ts b/src/message-agent-executor-paired.ts index 12db155..f782d09 100644 --- a/src/message-agent-executor-paired.ts +++ b/src/message-agent-executor-paired.ts @@ -1,10 +1,13 @@ import type { AgentOutput } from './agent-runner.js'; import { + completePairedTurn, + failPairedTurn, getLastHumanMessageSender, getLatestTurnNumber, getPairedTaskById, insertPairedTurnOutput, refreshPairedTaskExecutionLease, + releasePairedTaskExecutionLease, } from './db.js'; import { logger } from './logger.js'; import { @@ -13,6 +16,7 @@ import { } from './paired-execution-context.js'; import { resolvePairedFollowUpQueueAction } from './message-agent-executor-rules.js'; import { enqueuePairedFollowUpAfterEvent } from './message-runtime-follow-up.js'; +import type { PairedTurnIdentity } from './paired-turn-identity.js'; import type { PairedRoomRole } from './types.js'; type ExecutorLog = Pick; @@ -26,6 +30,7 @@ export interface PairedExecutionLifecycle { }): void; recordFinalOutputBeforeDelivery(outputText: string): void; completeImmediately(args: { status: 'succeeded' | 'failed' }): void; + markDelegated(): void; markStatus(status: 'succeeded' | 'failed'): void; markSawOutput(sawOutput: boolean): void; getSummary(): string | null; @@ -34,6 +39,7 @@ export interface PairedExecutionLifecycle { export function createPairedExecutionLifecycle(args: { pairedExecutionContext?: PreparedPairedExecutionContext; + pairedTurnIdentity?: PairedTurnIdentity; completedRole: PairedRoomRole; chatJid: string; runId: string; @@ -43,6 +49,7 @@ export function createPairedExecutionLifecycle(args: { }): PairedExecutionLifecycle { const { pairedExecutionContext, + pairedTurnIdentity, completedRole, chatJid, runId, @@ -55,10 +62,30 @@ export function createPairedExecutionLifecycle(args: { let pairedExecutionSummary: string | null = null; let pairedFinalOutput: string | null = null; let pairedExecutionCompleted = false; + let pairedExecutionDelegated = false; let pairedSawOutput = false; let pairedTurnOutputPersisted = false; + let pairedTurnStateFinalized = false; let leaseHeartbeatTimer: ReturnType | null = null; + const finalizePairedTurnState = ( + status: 'succeeded' | 'failed', + errorText?: string | null, + ) => { + if (!pairedTurnIdentity || pairedTurnStateFinalized) { + return; + } + if (status === 'succeeded') { + completePairedTurn(pairedTurnIdentity); + } else { + failPairedTurn({ + turnIdentity: pairedTurnIdentity, + error: errorText ?? pairedExecutionSummary, + }); + } + pairedTurnStateFinalized = true; + }; + const clearLeaseHeartbeat = () => { if (!leaseHeartbeatTimer) { return; @@ -188,6 +215,10 @@ export function createPairedExecutionLifecycle(args: { pairedExecutionCompleted = true; }, + markDelegated() { + pairedExecutionDelegated = true; + }, + markStatus(status) { pairedExecutionStatus = status; }, @@ -203,14 +234,34 @@ export function createPairedExecutionLifecycle(args: { async asyncFinalize() { clearLeaseHeartbeat(); - if (pairedExecutionContext && !pairedExecutionCompleted) { - const effectiveStatus = - completedRole === 'owner' && - pairedExecutionStatus === 'succeeded' && - !pairedSawOutput - ? 'failed' - : pairedExecutionStatus; + if (pairedExecutionContext && pairedExecutionDelegated) { + try { + releasePairedTaskExecutionLease({ + taskId: pairedExecutionContext.task.id, + runId, + }); + } catch (err) { + log.warn( + { + pairedTaskId: pairedExecutionContext.task.id, + runId, + err, + }, + 'Failed to release paired execution lease for delegated fallback handoff', + ); + } + pairedExecutionCompleted = true; + return; + } + const effectiveStatus = + completedRole === 'owner' && + pairedExecutionStatus === 'succeeded' && + !pairedSawOutput + ? 'failed' + : pairedExecutionStatus; + + if (pairedExecutionContext && !pairedExecutionCompleted) { if (effectiveStatus === 'succeeded') { try { persistPairedTurnOutputIfNeeded(); @@ -232,6 +283,8 @@ export function createPairedExecutionLifecycle(args: { pairedExecutionCompleted = true; } + finalizePairedTurnState(effectiveStatus); + if (!pairedExecutionContext) { return; } @@ -260,7 +313,7 @@ export function createPairedExecutionLifecycle(args: { const queueAction = resolvePairedFollowUpQueueAction({ completedRole, - executionStatus: pairedExecutionStatus, + executionStatus: effectiveStatus, sawOutput: pairedSawOutput, taskStatus: finishedTask?.status ?? null, }); @@ -274,7 +327,7 @@ export function createPairedExecutionLifecycle(args: { task: finishedTask, source: 'executor-recovery', completedRole, - executionStatus: pairedExecutionStatus, + executionStatus: effectiveStatus, sawOutput: pairedSawOutput, fallbackLastTurnOutputRole: pairedSawOutput ? completedRole : null, enqueueMessageCheck, @@ -286,7 +339,7 @@ export function createPairedExecutionLifecycle(args: { { taskId: pairedExecutionContext.task.id, role: completedRole, - pairedExecutionStatus, + pairedExecutionStatus: effectiveStatus, taskStatus: finishedTask.status, intentKind: followUpResult.intentKind, scheduled: followUpResult.scheduled, diff --git a/src/message-agent-executor.test.ts b/src/message-agent-executor.test.ts index b126d5f..6a1f9c7 100644 --- a/src/message-agent-executor.test.ts +++ b/src/message-agent-executor.test.ts @@ -60,7 +60,9 @@ vi.mock('./db.js', () => { [args.chatJid, args.taskId, args.taskUpdatedAt, args.intentKind].join(':'); return { + completePairedTurn: vi.fn(), createServiceHandoff: vi.fn(), + failPairedTurn: vi.fn(), getAllTasks: vi.fn(() => []), getLastHumanMessageSender: vi.fn(() => null), getLatestOpenPairedTaskForChat: vi.fn(() => undefined), @@ -68,7 +70,9 @@ vi.mock('./db.js', () => { getPairedTaskById: vi.fn(() => undefined), getPairedTurnOutputs: vi.fn(() => []), insertPairedTurnOutput: vi.fn(), + markPairedTurnRunning: vi.fn(), refreshPairedTaskExecutionLease: vi.fn(() => true), + releasePairedTaskExecutionLease: vi.fn(), reservePairedTurnReservation: vi.fn((args) => { const key = buildReservationKey(args); if (pairedTurnReservations.has(key)) { @@ -680,10 +684,169 @@ describe('runAgentForGroup room memory', () => { expect.any(Object), expect.any(Function), expect.any(Function), - {}, + expect.objectContaining({ + EJCLAW_PAIRED_TURN_ID: + 'paired-task-reviewer-failover-model:2026-03-31T00:00:00.000Z:reviewer-turn', + EJCLAW_PAIRED_TURN_ROLE: 'reviewer', + EJCLAW_PAIRED_TURN_INTENT: 'reviewer-turn', + }), ); }); + it('fails closed when a persisted paired turn revision mismatches the prepared execution context', async () => { + const group = { ...makeGroup(), folder: 'test-group' }; + + vi.mocked( + pairedExecutionContext.preparePairedExecutionContext, + ).mockReturnValue({ + task: { + id: 'paired-task-stale-revision', + chat_jid: 'group@test', + group_folder: 'test-group', + owner_service_id: 'claude', + reviewer_service_id: 'codex-review', + title: null, + source_ref: 'HEAD', + plan_notes: null, + round_trip_count: 0, + review_requested_at: null, + status: 'active', + arbiter_verdict: null, + arbiter_requested_at: null, + completion_reason: null, + created_at: '2026-04-10T00:00:00.000Z', + updated_at: '2026-04-10T01:00:00.000Z', + }, + workspace: null, + envOverrides: {}, + }); + + await expect( + runAgentForGroup(makeDeps(), { + group, + prompt: 'hello', + chatJid: 'group@test', + runId: 'run-stale-paired-turn-revision', + pairedTurnIdentity: { + turnId: + 'paired-task-stale-revision:2026-04-10T00:00:00.000Z:owner-follow-up', + taskId: 'paired-task-stale-revision', + taskUpdatedAt: '2026-04-10T00:00:00.000Z', + intentKind: 'owner-follow-up', + role: 'owner', + }, + }), + ).rejects.toThrow( + /task_updated_at does not match the prepared execution context/, + ); + + expect(agentRunner.runAgentProcess).not.toHaveBeenCalled(); + }); + + it('fails closed when a persisted paired turn revision mismatches the latest paired task fallback', async () => { + const group = { ...makeGroup(), folder: 'test-group' }; + + vi.mocked( + pairedExecutionContext.preparePairedExecutionContext, + ).mockReturnValue(undefined); + vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue({ + id: 'paired-task-stale-fallback', + chat_jid: 'group@test', + group_folder: 'test-group', + owner_service_id: 'claude', + reviewer_service_id: 'codex-review', + title: null, + source_ref: 'HEAD', + plan_notes: null, + round_trip_count: 0, + review_requested_at: null, + status: 'active', + arbiter_verdict: null, + arbiter_requested_at: null, + completion_reason: null, + created_at: '2026-04-10T00:00:00.000Z', + updated_at: '2026-04-10T01:00:00.000Z', + }); + + await expect( + runAgentForGroup(makeDeps(), { + group, + prompt: 'hello', + chatJid: 'group@test', + runId: 'run-stale-paired-turn-fallback', + pairedTurnIdentity: { + turnId: + 'paired-task-stale-fallback:2026-04-10T00:00:00.000Z:owner-follow-up', + taskId: 'paired-task-stale-fallback', + taskUpdatedAt: '2026-04-10T00:00:00.000Z', + intentKind: 'owner-follow-up', + role: 'owner', + }, + }), + ).rejects.toThrow(/task_updated_at does not match the latest paired task/); + + expect(agentRunner.runAgentProcess).not.toHaveBeenCalled(); + }); + + it('persists logical turn state transitions while a paired turn runs successfully', async () => { + const group = { ...makeGroup(), folder: 'test-group' }; + + vi.mocked( + pairedExecutionContext.preparePairedExecutionContext, + ).mockReturnValue({ + task: { + id: 'paired-task-stateful-owner', + chat_jid: 'group@test', + group_folder: 'test-group', + owner_service_id: 'claude', + reviewer_service_id: 'codex-review', + title: null, + source_ref: 'HEAD', + plan_notes: null, + round_trip_count: 0, + review_requested_at: null, + status: 'active', + arbiter_verdict: null, + arbiter_requested_at: null, + completion_reason: null, + created_at: '2026-04-10T00:00:00.000Z', + updated_at: '2026-04-10T00:00:00.000Z', + }, + workspace: null, + envOverrides: {}, + }); + + const result = await runAgentForGroup(makeDeps(), { + group, + prompt: 'hello', + chatJid: 'group@test', + runId: 'run-paired-turn-stateful-owner', + }); + + expect(result).toBe('success'); + expect(db.markPairedTurnRunning).toHaveBeenCalledWith({ + turnIdentity: { + turnId: + 'paired-task-stateful-owner:2026-04-10T00:00:00.000Z:owner-follow-up', + taskId: 'paired-task-stateful-owner', + taskUpdatedAt: '2026-04-10T00:00:00.000Z', + intentKind: 'owner-follow-up', + role: 'owner', + }, + executorServiceId: 'claude', + executorAgentType: 'claude-code', + runId: 'run-paired-turn-stateful-owner', + }); + expect(db.completePairedTurn).toHaveBeenCalledWith({ + turnId: + 'paired-task-stateful-owner:2026-04-10T00:00:00.000Z:owner-follow-up', + taskId: 'paired-task-stateful-owner', + taskUpdatedAt: '2026-04-10T00:00:00.000Z', + intentKind: 'owner-follow-up', + role: 'owner', + }); + }); + it('allows silent reviewer outputs', async () => { const group = { ...makeGroup(), folder: 'test-group', workDir: '/repo' }; vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue({ @@ -2615,6 +2778,109 @@ describe('runAgentForGroup Claude rotation', () => { ); }); + it('does not enqueue generic paired reviewer recovery after delegating to a fallback handoff', async () => { + vi.mocked( + sessionRecovery.shouldRetryFreshSessionOnAgentFailure, + ).mockReturnValue(true); + vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue({ + chat_jid: 'group@test', + owner_agent_type: 'claude-code', + reviewer_agent_type: 'claude-code', + arbiter_agent_type: null, + owner_service_id: 'claude', + reviewer_service_id: 'claude', + arbiter_service_id: null, + activated_at: null, + reason: null, + explicit: false, + }); + vi.mocked( + pairedExecutionContext.preparePairedExecutionContext, + ).mockReturnValue({ + task: { + id: 'paired-task-reviewer-handoff-delegated', + chat_jid: 'group@test', + group_folder: 'test-group', + owner_service_id: 'claude', + reviewer_service_id: 'claude', + title: null, + source_ref: 'HEAD', + plan_notes: null, + round_trip_count: 1, + review_requested_at: '2026-03-31T00:00:00.000Z', + status: 'in_review', + arbiter_verdict: null, + arbiter_requested_at: null, + completion_reason: null, + created_at: '2026-03-31T00:00:00.000Z', + updated_at: '2026-03-31T00:00:00.000Z', + }, + workspace: null, + envOverrides: {}, + }); + vi.mocked(db.getPairedTaskById).mockReturnValue({ + id: 'paired-task-reviewer-handoff-delegated', + chat_jid: 'group@test', + group_folder: 'test-group', + owner_service_id: 'claude', + reviewer_service_id: 'claude', + title: null, + source_ref: 'HEAD', + plan_notes: null, + round_trip_count: 1, + review_requested_at: '2026-03-31T00:00:00.000Z', + status: 'review_ready', + arbiter_verdict: null, + arbiter_requested_at: null, + completion_reason: null, + created_at: '2026-03-31T00:00:00.000Z', + updated_at: '2026-03-31T00:00:00.000Z', + }); + vi.mocked(agentRunner.runAgentProcess).mockImplementation( + async (_group, _input, _onProcess, onOutput) => { + await onOutput?.({ + status: 'success', + phase: 'final', + result: null, + }); + return { + status: 'success', + result: null, + }; + }, + ); + + const deps = makeDeps(); + const result = await runAgentForGroup(deps, { + group: makeGroup(), + prompt: 'please review', + chatJid: 'group@test', + runId: 'run-reviewer-delegated-handoff', + forcedRole: 'reviewer', + onOutput: async () => {}, + }); + + expect(result).toBe('success'); + expect(db.createServiceHandoff).toHaveBeenCalledWith( + expect.objectContaining({ + chat_jid: 'group@test', + paired_task_id: 'paired-task-reviewer-handoff-delegated', + paired_task_updated_at: '2026-03-31T00:00:00.000Z', + turn_id: + 'paired-task-reviewer-handoff-delegated:2026-03-31T00:00:00.000Z:reviewer-turn', + turn_intent_kind: 'reviewer-turn', + turn_role: 'reviewer', + source_role: 'reviewer', + target_role: 'reviewer', + intended_role: 'reviewer', + }), + ); + expect( + pairedExecutionContext.completePairedExecutionContext, + ).not.toHaveBeenCalled(); + expect(deps.queue.enqueueMessageCheck).not.toHaveBeenCalled(); + }); + it('drops a stale Claude session id before retrying a fresh session', async () => { const deps = { ...makeDeps(), diff --git a/src/message-agent-executor.ts b/src/message-agent-executor.ts index b52c51c..0b924c3 100644 --- a/src/message-agent-executor.ts +++ b/src/message-agent-executor.ts @@ -22,6 +22,7 @@ import { createServiceHandoff, getAllTasks, getLatestOpenPairedTaskForChat, + markPairedTurnRunning, } from './db.js'; import { GroupQueue } from './group-queue.js'; import { createScopedLogger } from './logger.js'; @@ -29,6 +30,10 @@ import { buildRoomMemoryBriefing } from './sqlite-memory-store.js'; import { preparePairedExecutionContext } from './paired-execution-context.js'; import { resolveCodexFallbackHandoff } from './paired-turn-fallback.js'; import { createPairedExecutionLifecycle } from './message-agent-executor-paired.js'; +import { + resolveRuntimePairedTurnIdentity, + type PairedTurnIdentity, +} from './paired-turn-identity.js'; import { resolveExecutionTarget } from './message-runtime-rules.js'; import { buildRoomRoleContext } from './room-role-context.js'; import { type AgentTriggerReason } from './agent-error-detection.js'; @@ -82,6 +87,7 @@ export async function runAgentForGroup( hasHumanMessage?: boolean; forcedRole?: PairedRoomRole; forcedAgentType?: AgentType; + pairedTurnIdentity?: PairedTurnIdentity; onOutput?: (output: AgentOutput) => Promise; }, ): Promise<'success' | 'error'> { @@ -189,6 +195,75 @@ export async function runAgentForGroup( roomRoleContext, hasHumanMessage: args.hasHumanMessage, }); + const runtimePairedTurnIdentity = + args.pairedTurnIdentity ?? + (pairedExecutionContext + ? resolveRuntimePairedTurnIdentity({ + taskId: pairedExecutionContext.task.id, + taskUpdatedAt: pairedExecutionContext.task.updated_at, + role: activeRole, + taskStatus: pairedExecutionContext.task.status, + hasHumanMessage: args.hasHumanMessage, + }) + : pairedTask + ? resolveRuntimePairedTurnIdentity({ + taskId: pairedTask.id, + taskUpdatedAt: pairedTask.updated_at, + role: activeRole, + taskStatus: pairedTask.status, + hasHumanMessage: args.hasHumanMessage, + }) + : undefined); + if (runtimePairedTurnIdentity) { + if (runtimePairedTurnIdentity.role !== activeRole) { + throw new Error( + `Paired turn ${runtimePairedTurnIdentity.turnId} cannot execute as ${activeRole}`, + ); + } + if ( + pairedExecutionContext && + runtimePairedTurnIdentity.taskId !== pairedExecutionContext.task.id + ) { + throw new Error( + `Paired turn ${runtimePairedTurnIdentity.turnId} task_id does not match the prepared execution context`, + ); + } + if ( + pairedExecutionContext && + runtimePairedTurnIdentity.taskUpdatedAt !== + pairedExecutionContext.task.updated_at + ) { + throw new Error( + `Paired turn ${runtimePairedTurnIdentity.turnId} task_updated_at does not match the prepared execution context`, + ); + } + if ( + !pairedExecutionContext && + pairedTask && + runtimePairedTurnIdentity.taskId !== pairedTask.id + ) { + throw new Error( + `Paired turn ${runtimePairedTurnIdentity.turnId} task_id does not match the latest paired task`, + ); + } + if ( + !pairedExecutionContext && + pairedTask && + runtimePairedTurnIdentity.taskUpdatedAt !== pairedTask.updated_at + ) { + throw new Error( + `Paired turn ${runtimePairedTurnIdentity.turnId} task_updated_at does not match the latest paired task`, + ); + } + } + if (runtimePairedTurnIdentity) { + markPairedTurnRunning({ + turnIdentity: runtimePairedTurnIdentity, + executorServiceId: effectiveServiceId, + executorAgentType: effectiveAgentType, + runId, + }); + } // Forced fallbacks run under a different agent runtime, so keep the // fallback session on its default model/effort unless explicitly configured // for that runtime elsewhere. @@ -203,6 +278,16 @@ export async function runAgentForGroup( pairedExecutionContext.envOverrides[effortKey] = roleConfig.effort; } } + if (pairedExecutionContext && runtimePairedTurnIdentity) { + pairedExecutionContext.envOverrides.EJCLAW_PAIRED_TURN_ID = + runtimePairedTurnIdentity.turnId; + pairedExecutionContext.envOverrides.EJCLAW_PAIRED_TURN_ROLE = + runtimePairedTurnIdentity.role; + pairedExecutionContext.envOverrides.EJCLAW_PAIRED_TURN_INTENT = + runtimePairedTurnIdentity.intentKind; + pairedExecutionContext.envOverrides.EJCLAW_PAIRED_TASK_UPDATED_AT = + runtimePairedTurnIdentity.taskUpdatedAt; + } const log = createScopedLogger({ chatJid, @@ -213,6 +298,7 @@ export async function runAgentForGroup( messageSeqEnd: endSeq ?? undefined, role: activeRole, serviceId: effectiveServiceId, + turnId: runtimePairedTurnIdentity?.turnId, }); log.info( { @@ -306,6 +392,7 @@ export async function runAgentForGroup( const effectivePrompt = moaEnrichedPrompt; const pairedExecutionLifecycle = createPairedExecutionLifecycle({ pairedExecutionContext, + pairedTurnIdentity: runtimePairedTurnIdentity, completedRole: roomRoleContext?.role ?? 'owner', chatJid, runId, @@ -349,8 +436,14 @@ export async function runAgentForGroup( createServiceHandoff({ chat_jid: chatJid, group_folder: group.folder, + paired_task_id: runtimePairedTurnIdentity?.taskId, + paired_task_updated_at: runtimePairedTurnIdentity?.taskUpdatedAt, + turn_id: runtimePairedTurnIdentity?.turnId, + turn_intent_kind: runtimePairedTurnIdentity?.intentKind, + turn_role: runtimePairedTurnIdentity?.role, ...handoffResolution.plan.handoff, }); + pairedExecutionLifecycle.markDelegated(); log.warn({ reason }, handoffResolution.plan.logMessage); return true; }; diff --git a/src/message-runtime-flow.test.ts b/src/message-runtime-flow.test.ts index 53ecc46..f3eb117 100644 --- a/src/message-runtime-flow.test.ts +++ b/src/message-runtime-flow.test.ts @@ -1,7 +1,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { _initTestDatabase } from './db.js'; -import { executeBotOnlyPairedFollowUpAction } from './message-runtime-flow.js'; +import { + executeBotOnlyPairedFollowUpAction, + executePendingPairedTurn, +} from './message-runtime-flow.js'; import { resetPairedFollowUpScheduleState, schedulePairedFollowUpOnce, @@ -121,3 +124,56 @@ describe('executeBotOnlyPairedFollowUpAction', () => { ); }); }); + +describe('executePendingPairedTurn', () => { + it('passes the explicit pending role through as forcedRole', async () => { + const executeTurn = vi.fn(async () => ({ + outputStatus: 'success' as const, + deliverySucceeded: true, + visiblePhase: 'final', + })); + + const result = await executePendingPairedTurn({ + pendingTurn: { + prompt: 'pending owner follow-up', + channel: {} as any, + cursor: null, + taskId: 'task-pending-owner-follow-up', + taskUpdatedAt: '2026-03-30T00:00:00.000Z', + intentKind: 'owner-follow-up', + role: 'owner', + }, + chatJid: 'group@test', + group: { + name: 'Test Group', + folder: 'test-group', + trigger: '@Andy', + added_at: '2026-03-30T00:00:00.000Z', + requiresTrigger: false, + agentType: 'codex', + }, + runId: 'run-pending-owner-forced-role', + log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() } as any, + saveState: vi.fn(), + lastAgentTimestamps: {}, + executeTurn, + getFixedRoleChannelName: () => 'discord-review', + }); + + expect(result).toBe(true); + expect(executeTurn).toHaveBeenCalledWith( + expect.objectContaining({ + deliveryRole: 'owner', + forcedRole: 'owner', + pairedTurnIdentity: { + turnId: + 'task-pending-owner-follow-up:2026-03-30T00:00:00.000Z:owner-follow-up', + taskId: 'task-pending-owner-follow-up', + taskUpdatedAt: '2026-03-30T00:00:00.000Z', + intentKind: 'owner-follow-up', + role: 'owner', + }, + }), + ); + }); +}); diff --git a/src/message-runtime-flow.ts b/src/message-runtime-flow.ts index 60e2aef..3ec285f 100644 --- a/src/message-runtime-flow.ts +++ b/src/message-runtime-flow.ts @@ -20,6 +20,8 @@ import { resolveCursorKey, resolveNextTurnAction, } from './message-runtime-rules.js'; +import type { ExecuteTurnFn } from './message-runtime-types.js'; +import { buildPairedTurnIdentity } from './paired-turn-identity.js'; import { type ScheduledPairedFollowUpIntentKind } from './paired-follow-up-scheduler.js'; import { hasReviewerLease } from './service-routing.js'; import type { @@ -35,9 +37,11 @@ export type PendingPairedTurn = { prompt: string; channel: Channel | null; cursor: string | number | null; + taskId: string; + taskUpdatedAt: string; intentKind: PairedTurnReservationIntentKind; cursorKey?: string; - role?: 'reviewer' | 'arbiter'; + role: PairedRoomRole; } | null; export type BotOnlyPairedFollowUpAction = @@ -127,6 +131,8 @@ export function buildPendingPairedTurn(args: { }), channel: resolveChannel(taskStatus), cursor, + taskId: task.id, + taskUpdatedAt: task.updated_at, intentKind: 'reviewer-turn', cursorKey: resolveCursorKey(chatJid, taskStatus), role: 'reviewer', @@ -145,6 +151,8 @@ export function buildPendingPairedTurn(args: { }), channel: resolveChannel(taskStatus), cursor, + taskId: task.id, + taskUpdatedAt: task.updated_at, intentKind: 'arbiter-turn', cursorKey: resolveCursorKey(chatJid, taskStatus), role: 'arbiter', @@ -156,7 +164,10 @@ export function buildPendingPairedTurn(args: { prompt: buildFinalizePendingPrompt({ turnOutputs }), channel: resolveChannel(taskStatus), cursor, + taskId: task.id, + taskUpdatedAt: task.updated_at, intentKind: 'finalize-owner-turn', + role: 'owner', }; } @@ -171,7 +182,10 @@ export function buildPendingPairedTurn(args: { }), channel: resolveChannel(taskStatus), cursor, + taskId: task.id, + taskUpdatedAt: task.updated_at, intentKind: 'owner-follow-up', + role: 'owner', }; } @@ -186,16 +200,7 @@ export async function executePendingPairedTurn(args: { log: typeof logger; saveState: () => void; lastAgentTimestamps: Record; - executeTurn: (args: { - group: RegisteredGroup; - prompt: string; - chatJid: string; - runId: string; - channel: Channel; - startSeq: number | null; - endSeq: number | null; - deliveryRole?: PairedRoomRole; - }) => Promise<{ deliverySucceeded: boolean }>; + executeTurn: ExecuteTurnFn; getFixedRoleChannelName: (role: 'reviewer' | 'arbiter') => string; }): Promise { const { @@ -211,7 +216,17 @@ export async function executePendingPairedTurn(args: { } = args; if (!pendingTurn.channel) { - const missingRole = pendingTurn.role ?? 'reviewer'; + if (pendingTurn.role === 'owner') { + log.error( + { + role: 'owner', + }, + 'Skipping paired turn because the owner channel is not available', + ); + return false; + } + + const missingRole = pendingTurn.role; log.error( { role: missingRole, @@ -239,6 +254,13 @@ export async function executePendingPairedTurn(args: { runId, channel: pendingTurn.channel, deliveryRole: pendingTurn.role, + forcedRole: pendingTurn.role, + pairedTurnIdentity: buildPairedTurnIdentity({ + taskId: pendingTurn.taskId, + taskUpdatedAt: pendingTurn.taskUpdatedAt, + intentKind: pendingTurn.intentKind, + role: pendingTurn.role, + }), startSeq: null, endSeq: null, }); @@ -321,6 +343,8 @@ export async function executeBotOnlyPairedFollowUpAction(args: { chatJid: string; runId: string; channel: Channel; + forcedRole?: PairedRoomRole; + pairedTurnIdentity?: ReturnType; startSeq: number | null; endSeq: number | null; }) => Promise<{ deliverySucceeded: boolean }>; @@ -395,6 +419,13 @@ export async function executeBotOnlyPairedFollowUpAction(args: { chatJid, runId, channel, + forcedRole: 'owner', + pairedTurnIdentity: buildPairedTurnIdentity({ + taskId: action.task.id, + taskUpdatedAt: action.task.updated_at, + intentKind: 'finalize-owner-turn', + role: 'owner', + }), startSeq: null, endSeq: null, }); diff --git a/src/message-runtime-handoffs.test.ts b/src/message-runtime-handoffs.test.ts new file mode 100644 index 0000000..fa28129 --- /dev/null +++ b/src/message-runtime-handoffs.test.ts @@ -0,0 +1,283 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('./config.js', () => ({ + SERVICE_SESSION_SCOPE: 'codex-main', +})); + +vi.mock('./db.js', () => ({ + claimServiceHandoff: vi.fn(() => true), + claimPairedTurnReservation: vi.fn(() => true), + completeServiceHandoffAndAdvanceTargetCursor: vi.fn(() => null), + failServiceHandoff: vi.fn(), + getPairedTurnOutputs: vi.fn(() => []), + getPendingServiceHandoffs: vi.fn(() => []), + reservePairedTurnReservation: vi.fn(() => true), + _clearPairedTurnReservationsForTests: vi.fn(), +})); + +vi.mock('./logger.js', () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +import * as db from './db.js'; +import { + enqueuePendingHandoffs, + processClaimedHandoff, +} from './message-runtime-handoffs.js'; +import type { Channel, RegisteredGroup } from './types.js'; + +function makeGroup(): RegisteredGroup { + return { + name: 'Test Group', + folder: 'test-group', + trigger: '@Andy', + added_at: '2026-04-10T00:00:00.000Z', + requiresTrigger: false, + agentType: 'codex', + }; +} + +function makeChannel(name: string, ownsChatJid = false): Channel { + return { + name, + connect: vi.fn(), + disconnect: vi.fn(), + isConnected: vi.fn(() => true), + ownsJid: vi.fn((jid: string) => ownsChatJid && jid === 'group@test'), + sendMessage: vi.fn(), + } as unknown as Channel; +} + +describe('message-runtime-handoffs', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('does not expose reviewer-targeted handoffs to the codex-main poller', () => { + vi.mocked(db.getPendingServiceHandoffs).mockImplementation( + (targetServiceId?: string) => + targetServiceId === 'codex-main' + ? [] + : ([ + { + id: 1, + chat_jid: 'group@test', + group_folder: 'test-group', + source_service_id: 'claude', + target_service_id: 'codex-review', + source_role: 'reviewer', + source_agent_type: 'claude-code', + target_role: 'reviewer', + target_agent_type: 'codex', + prompt: 'review retry', + status: 'pending', + start_seq: 1, + end_seq: 2, + reason: 'reviewer-auth-failure', + intended_role: 'reviewer', + created_at: '2026-04-10T00:00:00.000Z', + claimed_at: null, + completed_at: null, + last_error: null, + }, + ] as any), + ); + + const enqueueTask = vi.fn(); + + enqueuePendingHandoffs({ + enqueueTask, + processClaimedHandoff: vi.fn(), + }); + + expect(db.getPendingServiceHandoffs).toHaveBeenCalledWith('codex-main'); + expect(db.claimServiceHandoff).not.toHaveBeenCalled(); + expect(enqueueTask).not.toHaveBeenCalled(); + }); + + it('fails a claimed handoff closed when its intended role cannot be resolved', async () => { + const executeTurn = vi.fn(); + + await processClaimedHandoff({ + handoff: { + id: 7, + chat_jid: 'group@test', + group_folder: 'test-group', + source_service_id: 'claude', + target_service_id: 'codex-main', + source_role: 'reviewer', + source_agent_type: 'claude-code', + target_role: null, + target_agent_type: 'codex', + prompt: 'review retry', + status: 'claimed', + start_seq: 3, + end_seq: 4, + reason: 'unknown-failure', + intended_role: null, + created_at: '2026-04-10T00:00:00.000Z', + claimed_at: '2026-04-10T00:00:01.000Z', + completed_at: null, + last_error: null, + }, + getRegisteredGroups: () => ({ + 'group@test': makeGroup(), + }), + channels: [makeChannel('discord-main', true)], + executeTurn, + lastAgentTimestamps: {}, + saveState: vi.fn(), + }); + + expect(db.failServiceHandoff).toHaveBeenCalledWith( + 7, + 'Cannot resolve intended handoff role', + ); + expect(executeTurn).not.toHaveBeenCalled(); + }); + + it('executes a reviewer handoff with a fixed reviewer role and channel', async () => { + const executeTurn = vi.fn(async () => ({ + outputStatus: 'success' as const, + deliverySucceeded: true, + visiblePhase: 'final', + })); + + await processClaimedHandoff({ + handoff: { + id: 9, + chat_jid: 'group@test', + group_folder: 'test-group', + paired_task_id: 'task-reviewer-handoff', + paired_task_updated_at: '2026-04-10T00:00:00.000Z', + turn_id: 'task-reviewer-handoff:2026-04-10T00:00:00.000Z:reviewer-turn', + turn_intent_kind: 'reviewer-turn', + turn_role: 'reviewer', + source_service_id: 'claude', + target_service_id: 'codex-review', + source_role: 'reviewer', + source_agent_type: 'claude-code', + target_role: 'reviewer', + target_agent_type: 'codex', + prompt: 'review retry', + status: 'claimed', + start_seq: 5, + end_seq: 6, + reason: 'reviewer-auth-failure', + intended_role: 'reviewer', + created_at: '2026-04-10T00:00:00.000Z', + claimed_at: '2026-04-10T00:00:01.000Z', + completed_at: null, + last_error: null, + }, + getRegisteredGroups: () => ({ + 'group@test': makeGroup(), + }), + channels: [ + makeChannel('discord-main', true), + makeChannel('discord-review'), + ], + executeTurn, + lastAgentTimestamps: {}, + saveState: vi.fn(), + }); + + expect(executeTurn).toHaveBeenCalledWith( + expect.objectContaining({ + chatJid: 'group@test', + forcedRole: 'reviewer', + forcedAgentType: 'codex', + pairedTurnIdentity: { + turnId: + 'task-reviewer-handoff:2026-04-10T00:00:00.000Z:reviewer-turn', + taskId: 'task-reviewer-handoff', + taskUpdatedAt: '2026-04-10T00:00:00.000Z', + intentKind: 'reviewer-turn', + role: 'reviewer', + }, + channel: expect.objectContaining({ + name: 'discord-review', + }), + }), + ); + expect(db.failServiceHandoff).not.toHaveBeenCalled(); + }); + + it('recreates a pending reviewer retry after a claimed handoff delivery failure', async () => { + const executeTurn = vi.fn(async () => ({ + outputStatus: 'error' as const, + deliverySucceeded: false, + visiblePhase: 'final', + })); + const enqueueMessageCheck = vi.fn(); + const getPairedTaskById = vi.fn(() => ({ + id: 'task-reviewer-handoff-retry', + status: 'review_ready' as const, + round_trip_count: 1, + updated_at: '2026-04-10T00:00:00.000Z', + })); + + await processClaimedHandoff({ + handoff: { + id: 11, + chat_jid: 'group@test', + group_folder: 'test-group', + paired_task_id: 'task-reviewer-handoff-retry', + paired_task_updated_at: '2026-04-10T00:00:00.000Z', + turn_id: + 'task-reviewer-handoff-retry:2026-04-10T00:00:00.000Z:reviewer-turn', + turn_intent_kind: 'reviewer-turn', + turn_role: 'reviewer', + source_service_id: 'claude', + target_service_id: 'codex-review', + source_role: 'reviewer', + source_agent_type: 'claude-code', + target_role: 'reviewer', + target_agent_type: 'codex', + prompt: 'review retry after claimed handoff failure', + status: 'claimed', + start_seq: 7, + end_seq: 8, + reason: 'reviewer-auth-failure', + intended_role: 'reviewer', + created_at: '2026-04-10T00:00:00.000Z', + claimed_at: '2026-04-10T00:00:01.000Z', + completed_at: null, + last_error: null, + }, + getRegisteredGroups: () => ({ + 'group@test': makeGroup(), + }), + channels: [ + makeChannel('discord-main', true), + makeChannel('discord-review'), + ], + executeTurn, + lastAgentTimestamps: {}, + saveState: vi.fn(), + getPairedTaskById, + enqueueMessageCheck, + }); + + expect(db.failServiceHandoff).toHaveBeenCalledWith( + 11, + 'Handoff delivery failed', + ); + expect(getPairedTaskById).toHaveBeenCalledWith( + 'task-reviewer-handoff-retry', + ); + expect(db.reservePairedTurnReservation).toHaveBeenCalledWith( + expect.objectContaining({ + chatJid: 'group@test', + taskId: 'task-reviewer-handoff-retry', + taskUpdatedAt: '2026-04-10T00:00:00.000Z', + intentKind: 'reviewer-turn', + }), + ); + expect(enqueueMessageCheck).toHaveBeenCalledWith('group@test'); + }); +}); diff --git a/src/message-runtime-handoffs.ts b/src/message-runtime-handoffs.ts index b344ddd..990dea5 100644 --- a/src/message-runtime-handoffs.ts +++ b/src/message-runtime-handoffs.ts @@ -1,21 +1,25 @@ +import { SERVICE_SESSION_SCOPE } from './config.js'; import { getErrorMessage } from './utils.js'; import { claimServiceHandoff, completeServiceHandoffAndAdvanceTargetCursor, failServiceHandoff, - getAllPendingServiceHandoffs, + getPendingServiceHandoffs, type ServiceHandoff, } from './db.js'; import { findChannel, findChannelByName } from './router.js'; import { logger } from './logger.js'; +import { schedulePairedFollowUpWithMessageCheck } from './message-runtime-follow-up.js'; import { getFixedRoleChannelName, getMissingRoleChannelMessage, resolveHandoffCursorKey, resolveHandoffRoleOverride, } from './message-runtime-shared.js'; +import { buildPairedTurnIdentity } from './paired-turn-identity.js'; +import type { ScheduledPairedFollowUpIntentKind } from './paired-follow-up-scheduler.js'; import type { ExecuteTurnFn } from './message-runtime-types.js'; -import type { Channel, RegisteredGroup } from './types.js'; +import type { Channel, PairedTask, RegisteredGroup } from './types.js'; export function enqueuePendingHandoffs(args: { enqueueTask: ( @@ -25,7 +29,7 @@ export function enqueuePendingHandoffs(args: { ) => void; processClaimedHandoff: (handoff: ServiceHandoff) => Promise; }): void { - for (const handoff of getAllPendingServiceHandoffs()) { + for (const handoff of getPendingServiceHandoffs(SERVICE_SESSION_SCOPE)) { if (!claimServiceHandoff(handoff.id)) { continue; } @@ -36,6 +40,124 @@ export function enqueuePendingHandoffs(args: { } } +function requeueFailedClaimedPairedTurn(args: { + handoff: ServiceHandoff; + error: string; + getPairedTaskById?: + | (( + id: string, + ) => + | Pick + | undefined) + | undefined; + enqueueMessageCheck?: ((chatJid: string) => void) | undefined; +}): void { + const { handoff, error, getPairedTaskById, enqueueMessageCheck } = args; + if ( + !handoff.paired_task_id || + !handoff.paired_task_updated_at || + !handoff.turn_intent_kind || + !getPairedTaskById || + !enqueueMessageCheck + ) { + return; + } + + const task = getPairedTaskById(handoff.paired_task_id); + if (!task) { + logger.warn( + { + chatJid: handoff.chat_jid, + handoffId: handoff.id, + taskId: handoff.paired_task_id, + error, + }, + 'Skipped paired turn retry after claimed service handoff failure because the paired task no longer exists', + ); + return; + } + + if (!isScheduledPairedFollowUpIntentKind(handoff.turn_intent_kind)) { + logger.warn( + { + chatJid: handoff.chat_jid, + handoffId: handoff.id, + taskId: handoff.paired_task_id, + intentKind: handoff.turn_intent_kind, + error, + }, + 'Skipped paired turn retry after claimed service handoff failure because the persisted turn intent is not schedulable', + ); + return; + } + + if (task.updated_at !== handoff.paired_task_updated_at) { + logger.warn( + { + chatJid: handoff.chat_jid, + handoffId: handoff.id, + taskId: handoff.paired_task_id, + expectedTaskUpdatedAt: handoff.paired_task_updated_at, + actualTaskUpdatedAt: task.updated_at, + error, + }, + 'Skipped paired turn retry after claimed service handoff failure because the paired task revision changed', + ); + return; + } + + const scheduled = schedulePairedFollowUpWithMessageCheck({ + chatJid: handoff.chat_jid, + runId: `handoff-${handoff.id}-retry`, + task, + intentKind: handoff.turn_intent_kind, + enqueueMessageCheck: () => enqueueMessageCheck(handoff.chat_jid), + }); + logger.info( + { + chatJid: handoff.chat_jid, + handoffId: handoff.id, + taskId: task.id, + taskStatus: task.status, + taskUpdatedAt: task.updated_at, + intentKind: handoff.turn_intent_kind, + turnId: handoff.turn_id ?? null, + scheduled, + error, + }, + scheduled + ? 'Queued paired turn retry after claimed service handoff failure' + : 'Skipped duplicate paired turn retry after claimed service handoff failure while task state was unchanged', + ); +} + +function isScheduledPairedFollowUpIntentKind( + intentKind: ServiceHandoff['turn_intent_kind'], +): intentKind is ScheduledPairedFollowUpIntentKind { + return ( + intentKind === 'reviewer-turn' || + intentKind === 'arbiter-turn' || + intentKind === 'owner-follow-up' || + intentKind === 'finalize-owner-turn' + ); +} + +function failClaimedHandoff(args: { + handoff: ServiceHandoff; + error: string; + getPairedTaskById?: + | (( + id: string, + ) => + | Pick + | undefined) + | undefined; + enqueueMessageCheck?: ((chatJid: string) => void) | undefined; +}): void { + failServiceHandoff(args.handoff.id, args.error); + requeueFailedClaimedPairedTurn(args); +} + export async function processClaimedHandoff(args: { handoff: ServiceHandoff; getRegisteredGroups: () => Record; @@ -43,21 +165,80 @@ export async function processClaimedHandoff(args: { executeTurn: ExecuteTurnFn; lastAgentTimestamps: Record; saveState: () => void; + getPairedTaskById?: + | (( + id: string, + ) => + | Pick + | undefined) + | undefined; + enqueueMessageCheck?: ((chatJid: string) => void) | undefined; }): Promise { const { handoff } = args; const group = args.getRegisteredGroups()[handoff.chat_jid]; if (!group) { - failServiceHandoff(handoff.id, 'Group not registered on target service'); + failClaimedHandoff({ + handoff, + error: 'Group not registered on target service', + getPairedTaskById: args.getPairedTaskById, + enqueueMessageCheck: args.enqueueMessageCheck, + }); return; } const channel = findChannel(args.channels, handoff.chat_jid); if (!channel) { - failServiceHandoff(handoff.id, 'No channel owns handoff jid'); + failClaimedHandoff({ + handoff, + error: 'No channel owns handoff jid', + getPairedTaskById: args.getPairedTaskById, + enqueueMessageCheck: args.enqueueMessageCheck, + }); return; } const handoffRole = resolveHandoffRoleOverride(handoff); + if (!handoffRole) { + failClaimedHandoff({ + handoff, + error: 'Cannot resolve intended handoff role', + getPairedTaskById: args.getPairedTaskById, + enqueueMessageCheck: args.enqueueMessageCheck, + }); + logger.error( + { + chatJid: handoff.chat_jid, + handoffId: handoff.id, + targetServiceId: handoff.target_service_id, + targetRole: handoff.target_role ?? null, + intendedRole: handoff.intended_role ?? null, + reason: handoff.reason ?? null, + }, + 'Failed claimed service handoff because its intended role could not be resolved', + ); + return; + } + if (handoff.turn_role && handoff.turn_role !== handoffRole) { + failClaimedHandoff({ + handoff, + error: `Stored handoff turn_role ${handoff.turn_role} conflicts with resolved role ${handoffRole}`, + getPairedTaskById: args.getPairedTaskById, + enqueueMessageCheck: args.enqueueMessageCheck, + }); + logger.error( + { + chatJid: handoff.chat_jid, + handoffId: handoff.id, + turnId: handoff.turn_id ?? null, + turnRole: handoff.turn_role, + resolvedRole: handoffRole, + targetServiceId: handoff.target_service_id, + }, + 'Failed claimed service handoff because its persisted logical turn role conflicts with the resolved role', + ); + return; + } + let handoffChannel = channel; if (handoffRole === 'reviewer') { const reviewerChannel = findChannelByName( @@ -65,7 +246,12 @@ export async function processClaimedHandoff(args: { getFixedRoleChannelName('reviewer'), ); if (!reviewerChannel) { - failServiceHandoff(handoff.id, getMissingRoleChannelMessage('reviewer')); + failClaimedHandoff({ + handoff, + error: getMissingRoleChannelMessage('reviewer'), + getPairedTaskById: args.getPairedTaskById, + enqueueMessageCheck: args.enqueueMessageCheck, + }); return; } handoffChannel = reviewerChannel; @@ -75,19 +261,37 @@ export async function processClaimedHandoff(args: { getFixedRoleChannelName('arbiter'), ); if (!arbiterChannel) { - failServiceHandoff(handoff.id, getMissingRoleChannelMessage('arbiter')); + failClaimedHandoff({ + handoff, + error: getMissingRoleChannelMessage('arbiter'), + getPairedTaskById: args.getPairedTaskById, + enqueueMessageCheck: args.enqueueMessageCheck, + }); return; } handoffChannel = arbiterChannel; } const runId = `handoff-${handoff.id}`; + const pairedTurnIdentity = + handoff.paired_task_id && + handoff.paired_task_updated_at && + handoff.turn_intent_kind + ? buildPairedTurnIdentity({ + taskId: handoff.paired_task_id, + taskUpdatedAt: handoff.paired_task_updated_at, + intentKind: handoff.turn_intent_kind, + role: handoff.turn_role ?? handoffRole, + turnId: handoff.turn_id, + }) + : undefined; try { logger.info( { chatJid: handoff.chat_jid, handoffId: handoff.id, runId, + turnId: pairedTurnIdentity?.turnId ?? handoff.turn_id ?? null, handoffRole, targetRole: handoff.target_role ?? null, targetServiceId: handoff.target_service_id, @@ -108,10 +312,16 @@ export async function processClaimedHandoff(args: { endSeq: handoff.end_seq, forcedRole: handoffRole, forcedAgentType: handoff.target_agent_type, + pairedTurnIdentity, }); if (!result.deliverySucceeded) { - failServiceHandoff(handoff.id, 'Handoff delivery failed'); + failClaimedHandoff({ + handoff, + error: 'Handoff delivery failed', + getPairedTaskById: args.getPairedTaskById, + enqueueMessageCheck: args.enqueueMessageCheck, + }); return; } @@ -143,7 +353,12 @@ export async function processClaimedHandoff(args: { ); } catch (err) { const errorMessage = getErrorMessage(err); - failServiceHandoff(handoff.id, errorMessage); + failClaimedHandoff({ + handoff, + error: errorMessage, + getPairedTaskById: args.getPairedTaskById, + enqueueMessageCheck: args.enqueueMessageCheck, + }); logger.error( { chatJid: handoff.chat_jid, handoffId: handoff.id, err }, 'Claimed service handoff failed', diff --git a/src/message-runtime-queue.test.ts b/src/message-runtime-queue.test.ts index effbbec..e4d1889 100644 --- a/src/message-runtime-queue.test.ts +++ b/src/message-runtime-queue.test.ts @@ -12,6 +12,7 @@ import { runPendingPairedTurnIfNeeded, runQueuedGroupTurn, } from './message-runtime-queue.js'; +import { resetPairedFollowUpScheduleState } from './paired-follow-up-scheduler.js'; import type { Channel, PairedTask, RegisteredGroup } from './types.js'; function makeGroup(): RegisteredGroup { @@ -64,6 +65,7 @@ function makeChannel(): Channel { describe('message-runtime-queue', () => { beforeEach(() => { _initTestDatabase(); + resetPairedFollowUpScheduleState(); }); it('skips a pending paired turn when another run already claimed the same task revision', async () => { @@ -222,4 +224,123 @@ describe('message-runtime-queue', () => { expect(outcome).toBe(true); expect(executeTurn).not.toHaveBeenCalled(); }); + + it('always passes the explicit reviewer role for queued paired reviewer turns', async () => { + const task = makeTask(); + createPairedTask(task); + const executeTurn = vi.fn(async () => ({ + outputStatus: 'success' as const, + deliverySucceeded: true, + visiblePhase: 'final', + })); + + const outcome = await runQueuedGroupTurn({ + chatJid: task.chat_jid, + group: makeGroup(), + runId: 'run-reviewer-forced-role', + log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() } as any, + timezone: 'UTC', + missedMessages: [ + { + id: 'bot-reviewer-1', + chat_jid: task.chat_jid, + sender: 'reviewer-bot@test', + sender_name: 'reviewer', + content: 'review pending', + timestamp: '2026-03-30T00:00:02.000Z', + seq: 45, + is_bot_message: true, + }, + ], + task, + roleToChannel: { + owner: null, + reviewer: makeChannel(), + arbiter: null, + }, + ownerChannel: makeChannel(), + lastAgentTimestamps: {}, + saveState: vi.fn(), + executeTurn, + getFixedRoleChannelName: () => 'discord-review', + labelPairedSenders: (_chatJid, messages) => messages, + formatMessages: () => 'formatted prompt', + }); + + expect(outcome).toBe(true); + expect(executeTurn).toHaveBeenCalledWith( + expect.objectContaining({ + deliveryRole: 'reviewer', + forcedRole: 'reviewer', + pairedTurnIdentity: { + turnId: 'task-queue-claim:2026-03-30T00:00:00.000Z:reviewer-turn', + taskId: 'task-queue-claim', + taskUpdatedAt: '2026-03-30T00:00:00.000Z', + intentKind: 'reviewer-turn', + role: 'reviewer', + }, + }), + ); + }); + + it('always passes the explicit owner role for queued paired owner turns', async () => { + const task = makeTask({ + status: 'active', + review_requested_at: null, + }); + createPairedTask(task); + const executeTurn = vi.fn(async () => ({ + outputStatus: 'success' as const, + deliverySucceeded: true, + visiblePhase: 'final', + })); + + const outcome = await runQueuedGroupTurn({ + chatJid: task.chat_jid, + group: makeGroup(), + runId: 'run-owner-forced-role', + log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() } as any, + timezone: 'UTC', + missedMessages: [ + { + id: 'human-owner-1', + chat_jid: task.chat_jid, + sender: 'user@test', + sender_name: 'User', + content: '다시 고쳐줘', + timestamp: '2026-03-30T00:00:03.000Z', + seq: 46, + is_bot_message: false, + }, + ], + task, + roleToChannel: { + owner: null, + reviewer: makeChannel(), + arbiter: null, + }, + ownerChannel: makeChannel(), + lastAgentTimestamps: {}, + saveState: vi.fn(), + executeTurn, + getFixedRoleChannelName: () => 'discord-review', + labelPairedSenders: (_chatJid, messages) => messages, + formatMessages: () => 'formatted prompt', + }); + + expect(outcome).toBe(true); + expect(executeTurn).toHaveBeenCalledWith( + expect.objectContaining({ + deliveryRole: 'owner', + forcedRole: 'owner', + pairedTurnIdentity: { + turnId: 'task-queue-claim:2026-03-30T00:00:00.000Z:owner-turn', + taskId: 'task-queue-claim', + taskUpdatedAt: '2026-03-30T00:00:00.000Z', + intentKind: 'owner-turn', + role: 'owner', + }, + }), + ); + }); }); diff --git a/src/message-runtime-queue.ts b/src/message-runtime-queue.ts index 355d1d3..485dde0 100644 --- a/src/message-runtime-queue.ts +++ b/src/message-runtime-queue.ts @@ -9,6 +9,7 @@ import { executePendingPairedTurn, isBotOnlyPairedRoomTurn, } from './message-runtime-flow.js'; +import { buildPairedTurnIdentity } from './paired-turn-identity.js'; import { advanceLastAgentCursor, resolveActiveRole, @@ -202,8 +203,23 @@ export async function runQueuedGroupTurn(args: { const turnChannel = turnRole === 'owner' ? args.ownerChannel : roleToChannel[turnRole]; const cursorKey = resolveCursorKeyForRole(chatJid, turnRole); - const forcedRole = - task && turnRole !== resolveActiveRole(taskStatus) ? turnRole : undefined; + const forcedRole = task ? turnRole : undefined; + const queuedIntentKind = task + ? resolveQueuedTurnReservationIntent({ + task, + turnRole, + hasHumanMessage: hasHumanMsg, + }) + : null; + const pairedTurnIdentity = + task && queuedIntentKind + ? buildPairedTurnIdentity({ + taskId: task.id, + taskUpdatedAt: task.updated_at, + intentKind: queuedIntentKind, + role: turnRole, + }) + : undefined; let prompt: string; if (turnRole === 'arbiter' && task) { @@ -257,16 +273,11 @@ export async function runQueuedGroupTurn(args: { } if (task) { - const intentKind = resolveQueuedTurnReservationIntent({ - task, - turnRole, - hasHumanMessage: hasHumanMsg, - }); const claimed = claimPairedTurnExecution({ chatJid, runId, task, - intentKind, + intentKind: queuedIntentKind!, }); if (!claimed) { log.info( @@ -274,7 +285,7 @@ export async function runQueuedGroupTurn(args: { taskId: task.id, taskStatus, taskUpdatedAt: task.updated_at, - intentKind, + intentKind: queuedIntentKind, turnRole, }, 'Skipped queued paired turn because the task revision was already claimed elsewhere', @@ -304,6 +315,7 @@ export async function runQueuedGroupTurn(args: { endSeq, hasHumanMessage: hasHumanMsg, forcedRole, + pairedTurnIdentity, }); if (!deliverySucceeded) { diff --git a/src/message-runtime-types.ts b/src/message-runtime-types.ts index dcd04b7..397e1b8 100644 --- a/src/message-runtime-types.ts +++ b/src/message-runtime-types.ts @@ -4,6 +4,7 @@ import type { PairedRoomRole, RegisteredGroup, } from './types.js'; +import type { PairedTurnIdentity } from './paired-turn-identity.js'; export type ExecuteTurnFn = (args: { group: RegisteredGroup; @@ -17,6 +18,7 @@ export type ExecuteTurnFn = (args: { hasHumanMessage?: boolean; forcedRole?: PairedRoomRole; forcedAgentType?: AgentType; + pairedTurnIdentity?: PairedTurnIdentity; }) => Promise<{ outputStatus: 'success' | 'error'; deliverySucceeded: boolean; diff --git a/src/message-runtime.test.ts b/src/message-runtime.test.ts index e098e2c..4ddcbf5 100644 --- a/src/message-runtime.test.ts +++ b/src/message-runtime.test.ts @@ -79,7 +79,9 @@ vi.mock('./db.js', () => { claimServiceHandoff: vi.fn(() => true), completeServiceHandoff: vi.fn(), completeServiceHandoffAndAdvanceTargetCursor: vi.fn(), + completePairedTurn: vi.fn(), failServiceHandoff: vi.fn(), + failPairedTurn: vi.fn(), getAllChats: vi.fn(() => []), getAllTasks: vi.fn(() => []), getAllPendingServiceHandoffs: vi.fn(() => []), @@ -135,6 +137,7 @@ vi.mock('./db.js', () => { getOpenWorkItem(chatJid), ), getLatestOpenPairedTaskForChat: vi.fn(() => undefined), + getPairedTaskById: vi.fn(() => undefined), getPairedTurnOutputs: vi.fn(() => []), getRecentChatMessages: vi.fn(() => []), createProducedWorkItem: vi.fn((input) => ({ @@ -157,6 +160,7 @@ vi.mock('./db.js', () => { })), markWorkItemDelivered: vi.fn(), markWorkItemDeliveryRetry: vi.fn(), + markPairedTurnRunning: vi.fn(), getLastBotFinalMessage: vi.fn(() => []), reservePairedTurnReservation: vi.fn((args) => { const key = buildReservationKey(args); @@ -193,8 +197,13 @@ vi.mock('./service-routing.js', () => ({ hasReviewerLease: vi.fn(() => false), getEffectiveChannelLease: vi.fn((chatJid: string) => ({ chat_jid: chatJid, + owner_agent_type: 'claude-code', + reviewer_agent_type: 'claude-code', + arbiter_agent_type: 'claude-code', owner_service_id: 'claude', reviewer_service_id: 'codex-main', + arbiter_service_id: 'claude-arbiter', + owner_failover_active: false, activated_at: null, reason: null, explicit: false, @@ -202,9 +211,13 @@ vi.mock('./service-routing.js', () => ({ resolveLeaseServiceId: vi.fn( ( lease: { + owner_agent_type?: string; + reviewer_agent_type?: string | null; + arbiter_agent_type?: string | null; owner_service_id: string; reviewer_service_id: string | null; arbiter_service_id?: string | null; + owner_failover_active?: boolean; }, role: 'owner' | 'reviewer' | 'arbiter', ) => { @@ -2987,9 +3000,13 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead vi.mocked(serviceRouting.hasReviewerLease).mockReturnValue(true); vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue({ chat_jid: chatJid, + owner_agent_type: 'claude-code', + reviewer_agent_type: 'claude-code', + arbiter_agent_type: null, owner_service_id: 'claude', reviewer_service_id: 'claude', arbiter_service_id: null, + owner_failover_active: false, activated_at: null, reason: null, explicit: false, @@ -3099,9 +3116,13 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead vi.mocked(serviceRouting.hasReviewerLease).mockReturnValue(true); vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue({ chat_jid: chatJid, + owner_agent_type: 'claude-code', + reviewer_agent_type: 'claude-code', + arbiter_agent_type: 'claude-code', owner_service_id: 'claude', reviewer_service_id: 'claude', - arbiter_service_id: null, + arbiter_service_id: 'claude-arbiter', + owner_failover_active: false, activated_at: null, reason: null, explicit: false, diff --git a/src/message-runtime.ts b/src/message-runtime.ts index 1447f22..8f35bcb 100644 --- a/src/message-runtime.ts +++ b/src/message-runtime.ts @@ -6,6 +6,7 @@ import { getMessagesSinceSeq, getLastBotFinalMessage, getLatestOpenPairedTaskForChat, + getPairedTaskById, } from './db.js'; import { isSessionCommandSenderAllowed, @@ -51,6 +52,7 @@ import { } from './message-runtime-follow-up.js'; import { runAgentForGroup } from './message-agent-executor.js'; import { MessageTurnController } from './message-turn-controller.js'; +import type { PairedTurnIdentity } from './paired-turn-identity.js'; import { type ScheduledPairedFollowUpIntentKind } from './paired-follow-up-scheduler.js'; import { transitionPairedTaskStatus } from './paired-execution-context-shared.js'; import { @@ -208,6 +210,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { hasHumanMessage?: boolean; forcedRole?: PairedRoomRole; forcedAgentType?: AgentType; + pairedTurnIdentity?: PairedTurnIdentity; }, ): Promise<'success' | 'error'> => runAgentForGroup( @@ -229,6 +232,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { hasHumanMessage: options?.hasHumanMessage, forcedRole: options?.forcedRole, forcedAgentType: options?.forcedAgentType, + pairedTurnIdentity: options?.pairedTurnIdentity, onOutput, }, ); @@ -245,6 +249,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { hasHumanMessage?: boolean; forcedRole?: PairedRoomRole; forcedAgentType?: AgentType; + pairedTurnIdentity?: PairedTurnIdentity; }): Promise<{ outputStatus: 'success' | 'error'; deliverySucceeded: boolean; @@ -346,6 +351,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { hasHumanMessage: args.hasHumanMessage, forcedRole: args.forcedRole, forcedAgentType: args.forcedAgentType, + pairedTurnIdentity: args.pairedTurnIdentity, }, ); @@ -421,6 +427,9 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { executeTurn, lastAgentTimestamps: deps.getLastAgentTimestamps(), saveState: deps.saveState, + getPairedTaskById, + enqueueMessageCheck: (chatJid) => + deps.queue.enqueueMessageCheck(chatJid), }); }, }); diff --git a/src/paired-follow-up-scheduler.test.ts b/src/paired-follow-up-scheduler.test.ts index e9ede8e..b35a996 100644 --- a/src/paired-follow-up-scheduler.test.ts +++ b/src/paired-follow-up-scheduler.test.ts @@ -16,7 +16,12 @@ import { _initTestDatabase, _initTestDatabaseFromFile, createPairedTask, + createServiceHandoff, + failServiceHandoff, getPairedTaskById, + getPairedTurnAttempts, + getPairedTurnById, + releasePairedTaskExecutionLease, } from './db.js'; import { buildPairedFollowUpKey, @@ -242,6 +247,140 @@ describe('paired follow-up scheduler', () => { expect(second).toBe(false); expect(enqueue).toHaveBeenCalledTimes(1); }); + + it('persists the same logical turn id across reservation and execution lease rows', () => { + const tempDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'ejclaw-paired-turn-id-'), + ); + const dbPath = path.join(tempDir, 'paired-state.db'); + + try { + const emptyDb = new Database(dbPath); + emptyDb.close(); + _initTestDatabaseFromFile(dbPath); + resetPairedFollowUpScheduleState(); + + const task = { + id: 'task-turn-id-persistence', + chat_jid: 'group@test', + group_folder: 'test-group', + owner_service_id: 'claude', + reviewer_service_id: 'codex-main', + owner_agent_type: 'claude-code', + reviewer_agent_type: 'codex', + arbiter_agent_type: null, + title: null, + source_ref: 'HEAD', + plan_notes: null, + review_requested_at: null, + arbiter_verdict: null, + arbiter_requested_at: null, + completion_reason: null, + created_at: '2026-04-10T00:00:00.000Z', + status: 'review_ready', + round_trip_count: 1, + updated_at: '2026-04-10T00:00:00.000Z', + } as const; + createPairedTask(task as any); + + expect( + schedulePairedFollowUpOnce({ + chatJid: task.chat_jid, + runId: 'run-turn-id-reservation', + task, + intentKind: 'reviewer-turn', + enqueue: vi.fn(), + }), + ).toBe(true); + expect( + claimPairedTurnExecution({ + chatJid: task.chat_jid, + runId: 'run-turn-id-claim', + task, + intentKind: 'reviewer-turn', + }), + ).toBe(true); + + const rawDatabase = new Database(dbPath, { readonly: true }); + const reservationRow = rawDatabase + .prepare( + ` + SELECT turn_id, turn_attempt_no, turn_role + FROM paired_turn_reservations + WHERE task_id = ? + AND intent_kind = ? + `, + ) + .get(task.id, 'reviewer-turn') as + | { turn_id: string; turn_attempt_no: number | null; turn_role: string } + | undefined; + const leaseRow = rawDatabase + .prepare( + ` + SELECT turn_id, turn_attempt_no, role + FROM paired_task_execution_leases + WHERE task_id = ? + `, + ) + .get(task.id) as + | { turn_id: string; turn_attempt_no: number | null; role: string } + | undefined; + const turnRow = rawDatabase + .prepare( + ` + SELECT turn_id, task_id, task_updated_at, role, intent_kind + FROM paired_turns + WHERE task_id = ? + `, + ) + .get(task.id) as + | { + turn_id: string; + task_id: string; + task_updated_at: string; + role: string; + intent_kind: string; + } + | undefined; + rawDatabase.close(); + + expect(reservationRow).toEqual({ + turn_id: + 'task-turn-id-persistence:2026-04-10T00:00:00.000Z:reviewer-turn', + turn_attempt_no: 1, + turn_role: 'reviewer', + }); + expect(leaseRow).toEqual({ + turn_id: + 'task-turn-id-persistence:2026-04-10T00:00:00.000Z:reviewer-turn', + turn_attempt_no: 1, + role: 'reviewer', + }); + expect(turnRow).toEqual({ + turn_id: + 'task-turn-id-persistence:2026-04-10T00:00:00.000Z:reviewer-turn', + task_id: task.id, + task_updated_at: task.updated_at, + role: 'reviewer', + intent_kind: 'reviewer-turn', + }); + expect( + getPairedTurnById( + 'task-turn-id-persistence:2026-04-10T00:00:00.000Z:reviewer-turn', + ), + ).toMatchObject({ + turn_id: + 'task-turn-id-persistence:2026-04-10T00:00:00.000Z:reviewer-turn', + state: 'running', + executor_service_id: CURRENT_SERVICE_ID, + attempt_no: 1, + }); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + _initTestDatabase(); + resetPairedFollowUpScheduleState(); + } + }); it('keeps different round trips schedulable', () => { const enqueue = vi.fn(); @@ -374,6 +513,137 @@ describe('paired follow-up scheduler', () => { expect(enqueue).toHaveBeenCalledTimes(1); }); + it('requeues the same reviewer logical turn after a failed fallback handoff', () => { + const firstEnqueue = vi.fn(); + const secondEnqueue = vi.fn(); + const currentReviewerServiceId = + CURRENT_AGENT_TYPE === 'codex' + ? CODEX_REVIEW_SERVICE_ID + : CLAUDE_SERVICE_ID; + const otherReviewerServiceId = + OTHER_AGENT_TYPE === 'codex' + ? CODEX_REVIEW_SERVICE_ID + : CLAUDE_SERVICE_ID; + const task = { + id: 'task-reviewer-handoff-retry', + chat_jid: 'group@test', + group_folder: 'test-group', + owner_service_id: CURRENT_SERVICE_ID, + reviewer_service_id: OTHER_SERVICE_ID, + title: null, + source_ref: 'HEAD', + plan_notes: null, + review_requested_at: null, + arbiter_verdict: null, + arbiter_requested_at: null, + completion_reason: null, + created_at: '2026-04-10T00:00:00.000Z', + status: 'review_ready', + round_trip_count: 1, + updated_at: '2026-04-10T00:00:00.000Z', + } as const; + createPairedTask(task as any); + + expect( + schedulePairedFollowUpOnce({ + chatJid: task.chat_jid, + runId: 'run-reviewer-schedule-1', + task, + intentKind: 'reviewer-turn', + enqueue: firstEnqueue, + }), + ).toBe(true); + expect( + claimPairedTurnExecution({ + chatJid: task.chat_jid, + runId: 'run-reviewer-claim-1', + task, + intentKind: 'reviewer-turn', + }), + ).toBe(true); + + const handoff = createServiceHandoff({ + chat_jid: task.chat_jid, + group_folder: task.group_folder, + paired_task_id: task.id, + paired_task_updated_at: task.updated_at, + turn_intent_kind: 'reviewer-turn', + turn_role: 'reviewer', + source_service_id: currentReviewerServiceId, + target_service_id: otherReviewerServiceId, + source_role: 'reviewer', + target_role: 'reviewer', + source_agent_type: CURRENT_AGENT_TYPE, + target_agent_type: OTHER_AGENT_TYPE, + prompt: 'review retry after fallback', + intended_role: 'reviewer', + }); + + failServiceHandoff(handoff.id, 'fallback handoff failed'); + releasePairedTaskExecutionLease({ + taskId: task.id, + runId: 'run-reviewer-claim-1', + }); + + expect( + getPairedTurnById( + 'task-reviewer-handoff-retry:2026-04-10T00:00:00.000Z:reviewer-turn', + ), + ).toMatchObject({ + state: 'failed', + attempt_no: 1, + last_error: 'fallback handoff failed', + }); + + expect( + schedulePairedFollowUpOnce({ + chatJid: task.chat_jid, + runId: 'run-reviewer-schedule-2', + task, + intentKind: 'reviewer-turn', + enqueue: secondEnqueue, + }), + ).toBe(true); + expect(secondEnqueue).toHaveBeenCalledTimes(1); + expect( + getPairedTurnById( + 'task-reviewer-handoff-retry:2026-04-10T00:00:00.000Z:reviewer-turn', + ), + ).toMatchObject({ + state: 'failed', + attempt_no: 1, + last_error: 'fallback handoff failed', + }); + expect( + getPairedTurnAttempts( + 'task-reviewer-handoff-retry:2026-04-10T00:00:00.000Z:reviewer-turn', + ), + ).toMatchObject([ + { + attempt_no: 1, + state: 'failed', + last_error: 'fallback handoff failed', + }, + ]); + + expect( + claimPairedTurnExecution({ + chatJid: task.chat_jid, + runId: 'run-reviewer-claim-2', + task, + intentKind: 'reviewer-turn', + }), + ).toBe(true); + expect( + getPairedTurnById( + 'task-reviewer-handoff-retry:2026-04-10T00:00:00.000Z:reviewer-turn', + ), + ).toMatchObject({ + state: 'running', + attempt_no: 2, + }); + }); + it('blocks a fresh-refetched task revision from reclaiming the same turn while the execution lease is active', () => { const task = { id: 'task-1', @@ -560,6 +830,91 @@ describe('paired follow-up scheduler', () => { } }); + it('creates attempt 2 after restart reclaim without overwriting attempt 1', () => { + const tempDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'ejclaw-paired-attempt-restart-'), + ); + const dbPath = path.join(tempDir, 'paired-state.db'); + + try { + _initTestDatabaseFromFile(dbPath); + resetPairedFollowUpScheduleState(); + + const task = { + id: 'task-restart-attempt-history', + chat_jid: 'group@test', + group_folder: 'test-group', + owner_service_id: 'claude', + reviewer_service_id: 'codex-main', + title: null, + source_ref: 'HEAD', + plan_notes: null, + review_requested_at: null, + arbiter_verdict: null, + arbiter_requested_at: null, + completion_reason: null, + created_at: '2026-03-30T00:00:00.000Z', + status: 'review_ready', + round_trip_count: 1, + updated_at: '2026-03-30T00:00:00.000Z', + } as const; + createPairedTask(task as any); + + expect( + claimPairedTurnExecution({ + chatJid: task.chat_jid, + runId: 'run-restart-attempt-1', + task, + intentKind: 'reviewer-turn', + }), + ).toBe(true); + + _initTestDatabaseFromFile(dbPath); + const recoveredTask = getPairedTaskById(task.id); + expect(recoveredTask).toBeDefined(); + + expect( + claimPairedTurnExecution({ + chatJid: task.chat_jid, + runId: 'run-restart-attempt-2', + task: recoveredTask ?? task, + intentKind: 'reviewer-turn', + }), + ).toBe(true); + + expect( + getPairedTurnById( + 'task-restart-attempt-history:2026-03-30T00:00:00.000Z:reviewer-turn', + ), + ).toMatchObject({ + state: 'running', + attempt_no: 2, + }); + expect( + getPairedTurnAttempts( + 'task-restart-attempt-history:2026-03-30T00:00:00.000Z:reviewer-turn', + ), + ).toMatchObject([ + { + attempt_no: 1, + state: 'cancelled', + executor_service_id: CURRENT_SERVICE_ID, + executor_agent_type: CURRENT_AGENT_TYPE, + }, + { + attempt_no: 2, + state: 'running', + executor_service_id: CURRENT_SERVICE_ID, + executor_agent_type: CURRENT_AGENT_TYPE, + }, + ]); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + _initTestDatabase(); + resetPairedFollowUpScheduleState(); + } + }); + it('preserves legacy execution leases that belong to another service during startup cleanup', () => { const tempDir = fs.mkdtempSync( path.join(os.tmpdir(), 'ejclaw-paired-lease-preserve-'), diff --git a/src/paired-turn-identity.ts b/src/paired-turn-identity.ts new file mode 100644 index 0000000..448733f --- /dev/null +++ b/src/paired-turn-identity.ts @@ -0,0 +1,103 @@ +import type { + PairedRoomRole, + PairedTaskStatus, + PairedTurnReservationIntentKind, +} from './types.js'; + +export interface PairedTurnIdentity { + turnId: string; + taskId: string; + taskUpdatedAt: string; + intentKind: PairedTurnReservationIntentKind; + role: PairedRoomRole; +} + +export function resolvePairedTurnRole( + intentKind: PairedTurnReservationIntentKind, +): PairedRoomRole { + switch (intentKind) { + case 'reviewer-turn': + return 'reviewer'; + case 'arbiter-turn': + return 'arbiter'; + case 'owner-turn': + case 'owner-follow-up': + case 'finalize-owner-turn': + default: + return 'owner'; + } +} + +export function buildPairedTurnId(args: { + taskId: string; + taskUpdatedAt: string; + intentKind: PairedTurnReservationIntentKind; +}): string { + return [args.taskId, args.taskUpdatedAt, args.intentKind].join(':'); +} + +export function buildPairedTurnIdentity(args: { + taskId: string; + taskUpdatedAt: string; + intentKind: PairedTurnReservationIntentKind; + role?: PairedRoomRole; + turnId?: string | null; +}): PairedTurnIdentity { + const role = args.role ?? resolvePairedTurnRole(args.intentKind); + if (role !== resolvePairedTurnRole(args.intentKind)) { + throw new Error( + `paired turn identity role mismatch: ${role} does not match ${args.intentKind}`, + ); + } + + return { + turnId: + args.turnId ?? + buildPairedTurnId({ + taskId: args.taskId, + taskUpdatedAt: args.taskUpdatedAt, + intentKind: args.intentKind, + }), + taskId: args.taskId, + taskUpdatedAt: args.taskUpdatedAt, + intentKind: args.intentKind, + role, + }; +} + +export function resolveOwnerTurnIntentKind(args: { + taskStatus?: PairedTaskStatus | null; + hasHumanMessage?: boolean; +}): 'owner-turn' | 'owner-follow-up' | 'finalize-owner-turn' { + if (args.hasHumanMessage) { + return 'owner-turn'; + } + if (args.taskStatus === 'merge_ready') { + return 'finalize-owner-turn'; + } + return 'owner-follow-up'; +} + +export function resolveRuntimePairedTurnIdentity(args: { + taskId: string; + taskUpdatedAt: string; + role: PairedRoomRole; + taskStatus?: PairedTaskStatus | null; + hasHumanMessage?: boolean; +}): PairedTurnIdentity { + const intentKind = + args.role === 'reviewer' + ? 'reviewer-turn' + : args.role === 'arbiter' + ? 'arbiter-turn' + : resolveOwnerTurnIntentKind({ + taskStatus: args.taskStatus, + hasHumanMessage: args.hasHumanMessage, + }); + return buildPairedTurnIdentity({ + taskId: args.taskId, + taskUpdatedAt: args.taskUpdatedAt, + intentKind, + role: args.role, + }); +}