Finalize turn-role invariants and legacy migration cutover

This commit is contained in:
ejclaw
2026-04-10 11:44:34 +09:00
parent 7753ee46f0
commit c67bd1c622
45 changed files with 15014 additions and 1381 deletions

View File

@@ -51,7 +51,8 @@ Per-room routing uses an explicit assignment model:
- `room_mode`: `single` or `tribunal` - `room_mode`: `single` or `tribunal`
- `owner_agent_type`: `codex` or `claude-code` - `owner_agent_type`: `codex` or `claude-code`
- Public room assignment uses `assign_room` - 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: Operationally:
@@ -91,7 +92,7 @@ restricted environment variables and snapshot checks:
## Key Files ## Key Files
| File | Purpose | | File | Purpose |
|------|---------| | ------------------------- | ---------------------------------------------------------- |
| `src/index.ts` | Orchestrator: state, message loop, agent invocation | | `src/index.ts` | Orchestrator: state, message loop, agent invocation |
| `src/agent-runner.ts` | Spawns agent processes, manages env/sessions/skills | | `src/agent-runner.ts` | Spawns agent processes, manages env/sessions/skills |
| `src/verification.ts` | Fixed-profile verification execution with snapshot checks | | `src/verification.ts` | Fixed-profile verification execution with snapshot checks |

View File

@@ -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 같은 명시적 예외 파일에만 존재

View File

@@ -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.

View File

@@ -1,7 +1,10 @@
import { describe, it, expect, beforeEach } from 'vitest';
import fs from 'fs'; 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 { Database } from 'bun:sqlite';
import { detectAssignedRooms, run as runEnvironment } from './environment.js';
/** /**
* Tests for the environment check step. * Tests for the environment check step.
@@ -17,58 +20,174 @@ describe('environment detection', () => {
}); });
}); });
describe('registered groups DB query', () => { describe('assigned rooms detection', () => {
let db: Database; const tempDirs: string[] = [];
beforeEach(() => { afterEach(() => {
db = new Database(':memory:'); for (const dir of tempDirs.splice(0)) {
db.exec(`CREATE TABLE IF NOT EXISTS registered_groups ( fs.rmSync(dir, { recursive: true, force: true });
}
});
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 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 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, jid TEXT PRIMARY KEY,
name TEXT NOT NULL, agent_type TEXT
folder TEXT NOT NULL UNIQUE, )
trigger_pattern TEXT NOT NULL, `);
added_at TEXT NOT NULL, db.exec(`
agent_config TEXT, INSERT INTO registered_groups (jid, agent_type)
requires_trigger INTEGER DEFAULT 1 VALUES ('dc:legacy-room', 'claude-code')
)`); `);
db.close();
expect(detectAssignedRooms({ dbPath })).toBe(false);
});
}); });
it('returns 0 for empty table', () => { describe('environment step legacy-room handling', () => {
const row = db const originalCwd = process.cwd();
.prepare('SELECT COUNT(*) as count FROM registered_groups') const tempDirs: string[] = [];
.get() as { count: number };
expect(row.count).toBe(0); afterEach(() => {
process.chdir(originalCwd);
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
vi.restoreAllMocks();
}); });
it('returns correct count after inserts', () => { it('exits with failure when legacy-only room registrations need migration', async () => {
db.prepare( const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-env-run-'));
`INSERT INTO registered_groups (jid, name, folder, trigger_pattern, added_at, requires_trigger) tempDirs.push(tempDir);
VALUES (?, ?, ?, ?, ?, ?)`, process.chdir(tempDir);
).run(
'123@g.us', fs.mkdirSync(path.join(tempDir, 'store'), { recursive: true });
'Group 1', const db = new Database(path.join(tempDir, 'store', 'messages.db'));
'group-1', db.exec(`
'@Andy', CREATE TABLE registered_groups (
'2024-01-01T00:00:00.000Z', jid TEXT PRIMARY KEY,
1, 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( const exitSpy = vi
`INSERT INTO registered_groups (jid, name, folder, trigger_pattern, added_at, requires_trigger) .spyOn(process, 'exit')
VALUES (?, ?, ?, ?, ?, ?)`, .mockImplementation((code?: string | number | null | undefined) => {
).run( throw new Error(`process.exit:${code}`);
'456@g.us', });
'Group 2',
'group-2',
'@Andy',
'2024-01-01T00:00:00.000Z',
1,
);
const row = db await expect(runEnvironment([])).rejects.toThrow('process.exit:1');
.prepare('SELECT COUNT(*) as count FROM registered_groups') exitSpy.mockRestore();
.get() as { count: number };
expect(row.count).toBe(2);
}); });
}); });

View File

@@ -5,9 +5,6 @@
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
import { Database } from 'bun:sqlite';
import { STORE_DIR } from '../src/config.js';
import { logger } from '../src/logger.js'; import { logger } from '../src/logger.js';
import { import {
canUseLinuxBubblewrapReadonlySandbox, canUseLinuxBubblewrapReadonlySandbox,
@@ -17,8 +14,18 @@ import {
isHeadless, isHeadless,
isWSL, isWSL,
} from './platform.js'; } from './platform.js';
import {
detectRoomRegistrationState,
getLegacyMigrationGuidance,
} from './room-registration-state.js';
import { emitStatus } from './status.js'; import { emitStatus } from './status.js';
export function detectAssignedRooms(
options?: Parameters<typeof detectRoomRegistrationState>[0],
): boolean {
return detectRoomRegistrationState(options).assignedRooms > 0;
}
export async function run(_args: string[]): Promise<void> { export async function run(_args: string[]): Promise<void> {
const projectRoot = process.cwd(); const projectRoot = process.cwd();
@@ -30,9 +37,7 @@ export async function run(_args: string[]): Promise<void> {
const hasBubblewrap = platform === 'linux' && commandExists('bwrap'); const hasBubblewrap = platform === 'linux' && commandExists('bwrap');
const hasSocat = platform === 'linux' && commandExists('socat'); const hasSocat = platform === 'linux' && commandExists('socat');
const apparmorRestrictUnprivilegedUserns = const apparmorRestrictUnprivilegedUserns =
platform === 'linux' platform === 'linux' ? getAppArmorRestrictUnprivilegedUsernsValue() : null;
? getAppArmorRestrictUnprivilegedUsernsValue()
: null;
const hasBubblewrapReadonlySandboxCapability = const hasBubblewrapReadonlySandboxCapability =
platform === 'linux' && canUseLinuxBubblewrapReadonlySandbox(); platform === 'linux' && canUseLinuxBubblewrapReadonlySandbox();
@@ -42,26 +47,15 @@ export async function run(_args: string[]): Promise<void> {
const authDir = path.join(projectRoot, 'store', 'auth'); const authDir = path.join(projectRoot, 'store', 'auth');
const hasAuth = fs.existsSync(authDir) && fs.readdirSync(authDir).length > 0; const hasAuth = fs.existsSync(authDir) && fs.readdirSync(authDir).length > 0;
let hasRegisteredGroups = false; const roomState = detectRoomRegistrationState({
// Check JSON file first (pre-migration) projectRoot,
if (fs.existsSync(path.join(projectRoot, 'data', 'registered_groups.json'))) { dbPath: path.join(projectRoot, 'store', 'messages.db'),
hasRegisteredGroups = true; });
} else { const legacyMigrationGuidance = getLegacyMigrationGuidance(
// Check SQLite directly using better-sqlite3 (no sqlite3 CLI needed) roomState,
const dbPath = path.join(STORE_DIR, 'messages.db'); 'setup',
if (fs.existsSync(dbPath)) { );
try { const status = legacyMigrationGuidance ? 'failed' : 'success';
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
}
}
}
logger.info( logger.info(
{ {
@@ -69,7 +63,13 @@ export async function run(_args: string[]): Promise<void> {
wsl, wsl,
hasEnv, hasEnv,
hasAuth, hasAuth,
hasRegisteredGroups, assignedRooms: roomState.assignedRooms,
legacyRegisteredGroupRows: roomState.legacyRegisteredGroupRows,
hasLegacyRegisteredGroupsJson: roomState.hasLegacyRegisteredGroupsJson,
legacyRoomMigrationRequired: roomState.legacyRoomMigrationRequired,
pendingLegacyJsonStateFiles: roomState.pendingLegacyJsonStateFiles,
legacyJsonStateMigrationRequired:
roomState.legacyJsonStateMigrationRequired,
hasBubblewrap, hasBubblewrap,
hasSocat, hasSocat,
apparmorRestrictUnprivilegedUserns, apparmorRestrictUnprivilegedUserns,
@@ -84,14 +84,30 @@ export async function run(_args: string[]): Promise<void> {
IS_HEADLESS: headless, IS_HEADLESS: headless,
HAS_ENV: hasEnv, HAS_ENV: hasEnv,
HAS_AUTH: hasAuth, 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_BWRAP: hasBubblewrap,
HAS_SOCAT: hasSocat, HAS_SOCAT: hasSocat,
APPARMOR_RESTRICT_UNPRIVILEGED_USERNS: APPARMOR_RESTRICT_UNPRIVILEGED_USERNS:
apparmorRestrictUnprivilegedUserns ?? 'n/a', apparmorRestrictUnprivilegedUserns ?? 'n/a',
HAS_BWRAP_READONLY_SANDBOX_CAPABILITY: HAS_BWRAP_READONLY_SANDBOX_CAPABILITY:
hasBubblewrapReadonlySandboxCapability, hasBubblewrapReadonlySandboxCapability,
STATUS: 'success', ...(legacyMigrationGuidance
? {
ERROR: legacyMigrationGuidance.error,
NEXT_STEP: legacyMigrationGuidance.nextStep,
}
: {}),
STATUS: status,
LOG: 'logs/setup.log', LOG: 'logs/setup.log',
}); });
if (status === 'failed') process.exit(1);
} }

View File

@@ -13,6 +13,8 @@ const STEPS: Record<
runners: () => import('./runners.js'), runners: () => import('./runners.js'),
groups: () => import('./groups.js'), groups: () => import('./groups.js'),
register: () => import('./register.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'), 'restart-stack': () => import('./restart-stack.js'),
service: () => import('./service.js'), service: () => import('./service.js'),
verify: () => import('./verify.js'), verify: () => import('./verify.js'),

View File

@@ -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',
}),
);
});
});

116
setup/migrate-json-state.ts Normal file
View File

@@ -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<typeof openPersistentDatabase>,
): {
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<string, string>;
last_agent_timestamp?: Record<string, string>;
} | 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<typeof openPersistentDatabase>,
): {
migratedSessions: number;
renamed: boolean;
} {
const sessionsPath = path.join(DATA_DIR, 'sessions.json');
const sessions = readJsonFile(sessionsPath) as Record<string, string> | 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<void> {
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',
});
}

View File

@@ -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);
});
});

View File

@@ -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<Record<string, unknown>>
).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<void> {
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',
});
}

View File

@@ -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),
}));
/** vi.mock('../src/config.js', () => ({
* Tests for the register step. GROUPS_DIR: '/tmp/ejclaw-groups',
* }));
* Verifies: parameterized SQL (no injection), file templating,
* apostrophe in names, .env updates.
*/
function createTestDb(): Database { vi.mock('../src/db.js', () => ({
const db = new Database(':memory:'); assignRoom: assignRoomMock,
db.exec(`CREATE TABLE IF NOT EXISTS registered_groups ( initDatabase: initDatabaseMock,
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;
}
describe('parameterized SQL registration', () => { vi.mock('../src/group-folder.js', () => ({
let db: Database; isValidGroupFolder: isValidGroupFolderMock,
}));
beforeEach(() => { vi.mock('../src/logger.js', () => ({
db = createTestDb(); 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', () => { it('delegates room assignment to the canonical service without editing env or prompts', async () => {
db.prepare( const mkdirSpy = vi
`INSERT OR REPLACE INTO registered_groups .spyOn(fs, 'mkdirSync')
(jid, name, folder, trigger_pattern, added_at, agent_config, requires_trigger) .mockImplementation(() => undefined);
VALUES (?, ?, ?, ?, ?, NULL, ?)`, const readSpy = vi.spyOn(fs, 'readFileSync');
).run( const writeSpy = vi.spyOn(fs, 'writeFileSync');
'123@g.us',
'Test Group', await run([
'test-group', '--jid',
'dc:test-room',
'--name',
'Test Room',
'--trigger',
'@Andy', '@Andy',
'2024-01-01T00:00:00.000Z', '--folder',
1, 'test-room',
); ]);
const row = db expect(initDatabaseMock).toHaveBeenCalledTimes(1);
.prepare('SELECT * FROM registered_groups WHERE jid = ?') expect(assignRoomMock).toHaveBeenCalledWith('dc:test-room', {
.get('123@g.us') as { name: 'Test Room',
jid: string; folder: 'test-room',
name: string; trigger: '@Andy',
folder: string; requiresTrigger: true,
trigger_pattern: string; isMain: false,
requires_trigger: number; });
}; expect(mkdirSpy).toHaveBeenCalledWith('/tmp/ejclaw-groups/test-room/logs', {
recursive: true,
expect(row.jid).toBe('123@g.us'); });
expect(row.name).toBe('Test Group'); expect(readSpy).not.toHaveBeenCalled();
expect(row.folder).toBe('test-group'); expect(writeSpy).not.toHaveBeenCalled();
expect(row.trigger_pattern).toBe('@Andy'); expect(emitStatusMock).toHaveBeenCalledWith('REGISTER_CHANNEL', {
expect(row.requires_trigger).toBe(1); 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', () => { it('fails fast when deprecated assistant-name is passed', async () => {
const name = "O'Brien's Group"; const exitSpy = vi
.spyOn(process, 'exit')
.mockImplementation((code?: string | number | null | undefined) => {
throw new Error(`process.exit:${code}`);
});
db.prepare( await expect(
`INSERT OR REPLACE INTO registered_groups run([
(jid, name, folder, trigger_pattern, added_at, agent_config, requires_trigger) '--jid',
VALUES (?, ?, ?, ?, ?, NULL, ?)`, 'dc:test-room',
).run( '--name',
'456@g.us', 'Test Room',
name, '--trigger',
'obriens-group',
'@Andy', '@Andy',
'2024-01-01T00:00:00.000Z', '--folder',
0, 'test-room',
); '--assistant-name',
'Nova',
]),
).rejects.toThrow('process.exit:4');
const row = db expect(initDatabaseMock).not.toHaveBeenCalled();
.prepare('SELECT name FROM registered_groups WHERE jid = ?') expect(assignRoomMock).not.toHaveBeenCalled();
.get('456@g.us') as { expect(emitStatusMock).toHaveBeenCalledWith('REGISTER_CHANNEL', {
name: string; STATUS: 'failed',
}; ERROR: 'assistant_name_option_removed',
NEXT_STEP:
expect(row.name).toBe(name); 'Use a dedicated assistant identity configuration command instead',
LOG: 'logs/setup.log',
}); });
it('prevents SQL injection in JID field', () => { exitSpy.mockRestore();
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', () => { it('never writes prompt or env files during registration', async () => {
db.prepare( const mkdirSpy = vi
`INSERT OR REPLACE INTO registered_groups .spyOn(fs, 'mkdirSync')
(jid, name, folder, trigger_pattern, added_at, agent_config, requires_trigger) .mockImplementation(() => undefined);
VALUES (?, ?, ?, ?, ?, NULL, ?)`, const readSpy = vi.spyOn(fs, 'readFileSync');
).run( const writeSpy = vi.spyOn(fs, 'writeFileSync');
'dc:789',
'Personal', await run([
'main', '--jid',
'dc:main-room',
'--name',
'Main Room',
'--trigger',
'@Andy', '@Andy',
'2024-01-01T00:00:00.000Z', '--folder',
0, 'main-room',
); '--is-main',
'--no-trigger-required',
]);
const row = db expect(assignRoomMock).toHaveBeenCalledWith('dc:main-room', {
.prepare('SELECT requires_trigger FROM registered_groups WHERE jid = ?') name: 'Main Room',
.get('dc:789') as { requires_trigger: number }; folder: 'main-room',
trigger: '@Andy',
expect(row.requires_trigger).toBe(0); requiresTrigger: false,
isMain: true,
}); });
expect(mkdirSpy).toHaveBeenCalledTimes(1);
it('stores is_main flag', () => { expect(readSpy).not.toHaveBeenCalled();
db.prepare( expect(writeSpy).not.toHaveBeenCalled();
`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"');
}); });
}); });

View File

@@ -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. * EJClaw is Discord-only, so registrations must target Discord channel IDs.
* Uses parameterized SQL queries to prevent injection.
*/ */
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
import { Database } from 'bun:sqlite'; import { GROUPS_DIR } from '../src/config.js';
import { assignRoom, initDatabase } from '../src/db.js';
import { STORE_DIR } from '../src/config.js';
import { isValidGroupFolder } from '../src/group-folder.js'; import { isValidGroupFolder } from '../src/group-folder.js';
import { logger } from '../src/logger.js'; import { logger } from '../src/logger.js';
import { emitStatus } from './status.js'; import { emitStatus } from './status.js';
@@ -22,7 +20,7 @@ interface RegisterArgs {
channel: string; channel: string;
requiresTrigger: boolean; requiresTrigger: boolean;
isMain: boolean; isMain: boolean;
assistantName: string; assistantNameProvided: boolean;
} }
function parseArgs(args: string[]): RegisterArgs { function parseArgs(args: string[]): RegisterArgs {
@@ -34,7 +32,7 @@ function parseArgs(args: string[]): RegisterArgs {
channel: 'discord', channel: 'discord',
requiresTrigger: true, requiresTrigger: true,
isMain: false, isMain: false,
assistantName: 'Andy', assistantNameProvided: false,
}; };
for (let i = 0; i < args.length; i++) { for (let i = 0; i < args.length; i++) {
@@ -61,7 +59,8 @@ function parseArgs(args: string[]): RegisterArgs {
result.isMain = true; result.isMain = true;
break; break;
case '--assistant-name': case '--assistant-name':
result.assistantName = args[++i] || 'Andy'; result.assistantNameProvided = true;
i += 1;
break; break;
} }
} }
@@ -70,7 +69,6 @@ function parseArgs(args: string[]): RegisterArgs {
} }
export async function run(args: string[]): Promise<void> { export async function run(args: string[]): Promise<void> {
const projectRoot = process.cwd();
const parsed = parseArgs(args); const parsed = parseArgs(args);
if (!parsed.jid || !parsed.name || !parsed.trigger || !parsed.folder) { if (!parsed.jid || !parsed.name || !parsed.trigger || !parsed.folder) {
@@ -100,118 +98,33 @@ export async function run(args: string[]): Promise<void> {
process.exit(4); 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'); logger.info(parsed, 'Registering channel');
// Ensure data and store directories exist for fresh installs. initDatabase();
fs.mkdirSync(path.join(projectRoot, 'data'), { recursive: true }); assignRoom(parsed.jid, {
fs.mkdirSync(STORE_DIR, { recursive: true }); 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) fs.mkdirSync(path.join(GROUPS_DIR, parsed.folder, 'logs'), {
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'), {
recursive: true, recursive: true,
}); });
logger.info({ folder: parsed.folder }, 'Ensured group log directory exists');
// 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;
}
emitStatus('REGISTER_CHANNEL', { emitStatus('REGISTER_CHANNEL', {
JID: parsed.jid, JID: parsed.jid,
@@ -220,8 +133,6 @@ export async function run(args: string[]): Promise<void> {
CHANNEL: parsed.channel, CHANNEL: parsed.channel,
TRIGGER: parsed.trigger, TRIGGER: parsed.trigger,
REQUIRES_TRIGGER: parsed.requiresTrigger, REQUIRES_TRIGGER: parsed.requiresTrigger,
ASSISTANT_NAME: parsed.assistantName,
NAME_UPDATED: nameUpdated,
STATUS: 'success', STATUS: 'success',
LOG: 'logs/setup.log', LOG: 'logs/setup.log',
}); });

View File

@@ -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<string, number>;
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<string, number> = {};
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,
};
}

View File

@@ -11,7 +11,7 @@ import {
buildVerifySummary, buildVerifySummary,
detectChannelAuth, detectChannelAuth,
detectCredentials, detectCredentials,
loadRegisteredGroupsSummary, loadAssignedRoomsSummary,
loadRoleRoutingRequirementsSummary, loadRoleRoutingRequirementsSummary,
} from './verify-state.js'; } from './verify-state.js';
@@ -69,9 +69,10 @@ describe('verify state helpers', () => {
const dbPath = path.join(tempRoot, 'messages.db'); const dbPath = path.join(tempRoot, 'messages.db');
const db = new Database(dbPath); const db = new Database(dbPath);
db.exec(` db.exec(`
CREATE TABLE registered_groups ( CREATE TABLE room_settings (
jid TEXT NOT NULL, chat_jid TEXT PRIMARY KEY,
agent_type TEXT room_mode TEXT NOT NULL,
updated_at TEXT NOT NULL
); );
`); `);
db.exec(` db.exec(`
@@ -82,10 +83,9 @@ describe('verify state helpers', () => {
); );
`); `);
db.exec(` db.exec(`
INSERT INTO registered_groups (jid, agent_type) VALUES INSERT INTO room_settings (chat_jid, room_mode, updated_at) VALUES
('group-1', 'claude-code'), ('group-1', 'tribunal', '2024-01-01T00:00:00.000Z'),
('group-1', 'codex'), ('group-2', 'single', '2024-01-01T00:00:00.000Z');
('group-2', 'claude-code');
`); `);
db.exec(` db.exec(`
INSERT INTO paired_tasks (id, chat_jid, status) VALUES INSERT INTO paired_tasks (id, chat_jid, status) VALUES
@@ -94,13 +94,48 @@ describe('verify state helpers', () => {
`); `);
db.close(); db.close();
expect(loadRoleRoutingRequirementsSummary(dbPath)).toEqual({ expect(loadRoleRoutingRequirementsSummary({ dbPath })).toEqual({
tribunalRooms: 1, tribunalRooms: 1,
activeArbiterTasks: 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-')); const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-verify-'));
tempRoots.push(tempRoot); tempRoots.push(tempRoot);
const dbPath = path.join(tempRoot, 'messages.db'); const dbPath = path.join(tempRoot, 'messages.db');
@@ -109,22 +144,307 @@ describe('verify state helpers', () => {
CREATE TABLE registered_groups ( CREATE TABLE registered_groups (
jid TEXT PRIMARY KEY, jid TEXT PRIMARY KEY,
agent_type TEXT agent_type TEXT
); )
`); `);
db.exec(` db.exec(`
INSERT INTO registered_groups (jid, agent_type) VALUES INSERT INTO registered_groups (jid, agent_type) VALUES
('group-1', 'claude-code'), ('legacy-room', 'claude-code')
('group-2', 'codex'),
('group-3', 'codex');
`); `);
db.close(); db.close();
expect(loadRegisteredGroupsSummary(dbPath)).toEqual({ expect(loadAssignedRoomsSummary({ dbPath, projectRoot: tempRoot })).toEqual(
registeredGroups: 3, {
groupsByAgent: { assignedRooms: 0,
'claude-code': 1, roomsByOwnerAgent: {},
codex: 2, 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-review': 'configured',
'discord-arbiter': 'configured', 'discord-arbiter': 'configured',
}, },
2, {
{ codex: 1 }, assignedRooms: 2,
roomsByOwnerAgent: { codex: 1 },
legacyRegisteredGroupRows: 0,
hasLegacyRegisteredGroupsJson: false,
legacyRoomMigrationRequired: false,
pendingLegacyJsonStateFiles: [],
legacyJsonStateMigrationRequired: false,
},
), ),
).toMatchObject({ ).toMatchObject({
status: 'success', status: 'success',
configuredChannels: ['discord', 'discord-review', 'discord-arbiter'], configuredChannels: ['discord', 'discord-review', 'discord-arbiter'],
codexConfigured: true, reviewerChannelConfigured: true,
reviewConfigured: true, arbiterChannelConfigured: true,
servicesSummary: { ejclaw: 'running' }, servicesSummary: { ejclaw: 'running' },
}); });
}); });
@@ -171,8 +498,15 @@ describe('verify state helpers', () => {
[], [],
'missing', 'missing',
{}, {},
0, {
{}, assignedRooms: 0,
roomsByOwnerAgent: {},
legacyRegisteredGroupRows: 0,
hasLegacyRegisteredGroupsJson: false,
legacyRoomMigrationRequired: false,
pendingLegacyJsonStateFiles: [],
legacyJsonStateMigrationRequired: false,
},
), ),
).toMatchObject({ ).toMatchObject({
status: 'failed', status: 'failed',
@@ -190,14 +524,21 @@ describe('verify state helpers', () => {
[], [],
'configured', 'configured',
{ 'discord-review': 'configured' }, { 'discord-review': 'configured' },
1, {
{}, assignedRooms: 1,
roomsByOwnerAgent: {},
legacyRegisteredGroupRows: 0,
hasLegacyRegisteredGroupsJson: false,
legacyRoomMigrationRequired: false,
pendingLegacyJsonStateFiles: [],
legacyJsonStateMigrationRequired: false,
},
), ),
).toMatchObject({ ).toMatchObject({
status: 'failed', status: 'failed',
configuredChannels: ['discord-review'], configuredChannels: ['discord-review'],
codexConfigured: true, reviewerChannelConfigured: true,
reviewConfigured: false, arbiterChannelConfigured: false,
servicesSummary: { ejclaw: 'running' }, servicesSummary: { ejclaw: 'running' },
}); });
}); });
@@ -211,15 +552,22 @@ describe('verify state helpers', () => {
[], [],
'configured', 'configured',
{ discord: 'configured' }, { discord: 'configured' },
1, {
{}, assignedRooms: 1,
roomsByOwnerAgent: {},
legacyRegisteredGroupRows: 0,
hasLegacyRegisteredGroupsJson: false,
legacyRoomMigrationRequired: false,
pendingLegacyJsonStateFiles: [],
legacyJsonStateMigrationRequired: false,
},
{ tribunalRooms: 1 }, { tribunalRooms: 1 },
), ),
).toMatchObject({ ).toMatchObject({
status: 'failed', status: 'failed',
configuredChannels: ['discord'], configuredChannels: ['discord'],
codexConfigured: false, reviewerChannelConfigured: false,
reviewConfigured: false, arbiterChannelConfigured: false,
tribunalRooms: 1, tribunalRooms: 1,
}); });
}); });
@@ -236,17 +584,75 @@ describe('verify state helpers', () => {
discord: 'configured', discord: 'configured',
'discord-review': 'configured', 'discord-review': 'configured',
}, },
1, {
{}, assignedRooms: 1,
roomsByOwnerAgent: {},
legacyRegisteredGroupRows: 0,
hasLegacyRegisteredGroupsJson: false,
legacyRoomMigrationRequired: false,
pendingLegacyJsonStateFiles: [],
legacyJsonStateMigrationRequired: false,
},
{ tribunalRooms: 1, activeArbiterTasks: 1 }, { tribunalRooms: 1, activeArbiterTasks: 1 },
), ),
).toMatchObject({ ).toMatchObject({
status: 'failed', status: 'failed',
configuredChannels: ['discord', 'discord-review'], configuredChannels: ['discord', 'discord-review'],
codexConfigured: true, reviewerChannelConfigured: true,
reviewConfigured: false, arbiterChannelConfigured: false,
activeArbiterTasks: 1, 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'],
});
});
}); });

View File

@@ -3,34 +3,32 @@ import path from 'path';
import { Database } from 'bun:sqlite'; import { Database } from 'bun:sqlite';
import { STORE_DIR } from '../src/config.js';
import { readEnvFile } from '../src/env.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 { ServiceDef } from './service-defs.js';
import type { ServiceCheck } from './verify-services.js'; import type { ServiceCheck } from './verify-services.js';
export type CredentialsStatus = 'configured' | 'missing'; export type CredentialsStatus = 'configured' | 'missing';
export type VerifyStatus = 'success' | 'failed'; export type VerifyStatus = 'success' | 'failed';
export interface RegisteredGroupsSummary {
registeredGroups: number;
groupsByAgent: Record<string, number>;
}
export interface RoleRoutingRequirementsSummary { export interface RoleRoutingRequirementsSummary {
tribunalRooms: number; tribunalRooms: number;
activeArbiterTasks: number; activeArbiterTasks: number;
} }
export interface VerifySummary extends RegisteredGroupsSummary { export interface VerifySummary extends AssignedRoomsSummary {
status: VerifyStatus; status: VerifyStatus;
servicesSummary: Record<string, string>; servicesSummary: Record<string, string>;
configuredChannels: string[]; configuredChannels: string[];
channelAuth: Record<string, string>; channelAuth: Record<string, string>;
tribunalRooms: number; tribunalRooms: number;
activeArbiterTasks: number; activeArbiterTasks: number;
// Legacy status fields kept for backward-compatible setup output. reviewerChannelConfigured: boolean;
codexConfigured: boolean; arbiterChannelConfigured: boolean;
reviewConfigured: boolean;
} }
export function detectCredentials(projectRoot: string): CredentialsStatus { export function detectCredentials(projectRoot: string): CredentialsStatus {
@@ -70,47 +68,18 @@ export function detectChannelAuth(
return channelAuth; return channelAuth;
} }
export function loadRegisteredGroupsSummary( export function loadAssignedRoomsSummary(
dbPath = path.join(STORE_DIR, 'messages.db'), options?: Parameters<typeof detectRoomRegistrationState>[0],
): RegisteredGroupsSummary { ): AssignedRoomsSummary {
let registeredGroups = 0; return detectRoomRegistrationState(options);
const groupsByAgent: Record<string, number> = {};
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 loadRoleRoutingRequirementsSummary( export function loadRoleRoutingRequirementsSummary(
dbPath = path.join(STORE_DIR, 'messages.db'), options: {
dbPath?: string;
} = {},
): RoleRoutingRequirementsSummary { ): RoleRoutingRequirementsSummary {
const dbPath = options.dbPath ?? path.join(STORE_DIR, 'messages.db');
let tribunalRooms = 0; let tribunalRooms = 0;
let activeArbiterTasks = 0; let activeArbiterTasks = 0;
@@ -126,40 +95,15 @@ export function loadRoleRoutingRequirementsSummary(
.prepare( .prepare(
` `
SELECT COUNT(*) as count SELECT COUNT(*) as count
FROM (
SELECT chat_jid AS jid
FROM room_settings FROM room_settings
WHERE room_mode = 'tribunal' WHERE room_mode = 'tribunal'
UNION
SELECT jid
FROM registered_groups
GROUP BY jid
HAVING COUNT(DISTINCT agent_type) > 1
)
`, `,
) )
.get() as { count: number }; .get() as { count: number };
tribunalRooms = roomModeRow.count; 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 { } catch {
tribunalRooms = 0; tribunalRooms = 0;
} }
}
try { try {
const arbiterRow = db const arbiterRow = db
@@ -189,8 +133,7 @@ export function buildVerifySummary(
serviceDefs: ServiceDef[], serviceDefs: ServiceDef[],
credentials: CredentialsStatus, credentials: CredentialsStatus,
channelAuth: Record<string, string>, channelAuth: Record<string, string>,
registeredGroups: number, roomSummary: AssignedRoomsSummary,
groupsByAgent: Record<string, number>,
options: { options: {
tribunalRooms?: number; tribunalRooms?: number;
activeArbiterTasks?: number; activeArbiterTasks?: number;
@@ -202,20 +145,24 @@ export function buildVerifySummary(
(service) => service.status === 'running', (service) => service.status === 'running',
); );
const hasOwnerCapableChannel = 'discord' in channelAuth; const hasOwnerCapableChannel = 'discord' in channelAuth;
const codexConfigured = 'discord-review' in channelAuth; const reviewerChannelConfigured = 'discord-review' in channelAuth;
const reviewConfigured = 'discord-arbiter' in channelAuth; const arbiterChannelConfigured = 'discord-arbiter' in channelAuth;
const tribunalRooms = options.tribunalRooms ?? 0; const tribunalRooms = options.tribunalRooms ?? 0;
const activeArbiterTasks = options.activeArbiterTasks ?? 0; const activeArbiterTasks = options.activeArbiterTasks ?? 0;
const reviewerConfigured = tribunalRooms === 0 || codexConfigured; const reviewerRoutingSatisfied =
const arbiterConfigured = activeArbiterTasks === 0 || reviewConfigured; tribunalRooms === 0 || reviewerChannelConfigured;
const arbiterRoutingSatisfied =
activeArbiterTasks === 0 || arbiterChannelConfigured;
const status = const status =
allConfiguredServicesRunning && allConfiguredServicesRunning &&
credentials === 'configured' && credentials === 'configured' &&
hasOwnerCapableChannel && hasOwnerCapableChannel &&
reviewerConfigured && reviewerRoutingSatisfied &&
arbiterConfigured && arbiterRoutingSatisfied &&
registeredGroups > 0 roomSummary.assignedRooms > 0 &&
!roomSummary.legacyRoomMigrationRequired &&
!roomSummary.legacyJsonStateMigrationRequired
? 'success' ? 'success'
: 'failed'; : 'failed';
@@ -231,9 +178,8 @@ export function buildVerifySummary(
channelAuth, channelAuth,
tribunalRooms, tribunalRooms,
activeArbiterTasks, activeArbiterTasks,
registeredGroups, ...roomSummary,
groupsByAgent, reviewerChannelConfigured,
codexConfigured, arbiterChannelConfigured,
reviewConfigured,
}; };
} }

View File

@@ -7,15 +7,18 @@
* *
* Uses better-sqlite3 directly (no sqlite3 CLI), platform-aware service checks. * Uses better-sqlite3 directly (no sqlite3 CLI), platform-aware service checks.
*/ */
import path from 'path';
import { logger } from '../src/logger.js'; import { logger } from '../src/logger.js';
import { getServiceManager } from './platform.js'; import { getServiceManager } from './platform.js';
import { getLegacyMigrationGuidance } from './room-registration-state.js';
import { getServiceDefs } from './service-defs.js'; import { getServiceDefs } from './service-defs.js';
import { emitStatus } from './status.js'; import { emitStatus } from './status.js';
import { import {
buildVerifySummary, buildVerifySummary,
detectChannelAuth, detectChannelAuth,
detectCredentials, detectCredentials,
loadRegisteredGroupsSummary, loadAssignedRoomsSummary,
loadRoleRoutingRequirementsSummary, loadRoleRoutingRequirementsSummary,
} from './verify-state.js'; } from './verify-state.js';
import { getServiceChecks } from './verify-services.js'; import { getServiceChecks } from './verify-services.js';
@@ -40,29 +43,33 @@ export async function run(_args: string[]): Promise<void> {
const credentials = detectCredentials(projectRoot); const credentials = detectCredentials(projectRoot);
const channelAuth = detectChannelAuth(); const channelAuth = detectChannelAuth();
const { registeredGroups, groupsByAgent } = loadRegisteredGroupsSummary(); const dbPath = path.join(projectRoot, 'store', 'messages.db');
const roomSummary = loadAssignedRoomsSummary({ projectRoot, dbPath });
const { tribunalRooms, activeArbiterTasks } = const { tribunalRooms, activeArbiterTasks } =
loadRoleRoutingRequirementsSummary(); loadRoleRoutingRequirementsSummary({ dbPath });
const { const {
status: baseStatus, status: baseStatus,
servicesSummary, servicesSummary,
configuredChannels, configuredChannels,
tribunalRooms: detectedTribunalRooms, tribunalRooms: detectedTribunalRooms,
activeArbiterTasks: detectedActiveArbiterTasks, activeArbiterTasks: detectedActiveArbiterTasks,
codexConfigured, reviewerChannelConfigured,
reviewConfigured, arbiterChannelConfigured,
} = buildVerifySummary( } = buildVerifySummary(
services, services,
serviceDefs, serviceDefs,
credentials, credentials,
channelAuth, channelAuth,
registeredGroups, roomSummary,
groupsByAgent,
{ {
tribunalRooms, tribunalRooms,
activeArbiterTasks, activeArbiterTasks,
}, },
); );
const legacyMigrationGuidance = getLegacyMigrationGuidance(
roomSummary,
'verification',
);
const status = baseStatus; const status = baseStatus;
logger.info( logger.info(
@@ -85,10 +92,24 @@ export async function run(_args: string[]): Promise<void> {
CHANNEL_AUTH: JSON.stringify(channelAuth), CHANNEL_AUTH: JSON.stringify(channelAuth),
TRIBUNAL_ROOMS: detectedTribunalRooms, TRIBUNAL_ROOMS: detectedTribunalRooms,
ACTIVE_ARBITER_TASKS: detectedActiveArbiterTasks, ACTIVE_ARBITER_TASKS: detectedActiveArbiterTasks,
REGISTERED_GROUPS: registeredGroups, ASSIGNED_ROOMS: roomSummary.assignedRooms,
GROUPS_BY_AGENT: JSON.stringify(groupsByAgent), ROOMS_BY_OWNER_AGENT: JSON.stringify(roomSummary.roomsByOwnerAgent),
CODEX_CONFIGURED: codexConfigured, LEGACY_REGISTERED_GROUP_ROWS: roomSummary.legacyRegisteredGroupRows,
REVIEW_CONFIGURED: reviewConfigured, 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, STATUS: status,
LOG: 'logs/setup.log', LOG: 'logs/setup.log',
}); });

File diff suppressed because it is too large Load Diff

209
src/db.ts
View File

@@ -1,5 +1,6 @@
import { Database } from 'bun:sqlite'; import { Database } from 'bun:sqlite';
import fs from 'fs'; import fs from 'fs';
import path from 'path';
import { import {
ASSISTANT_NAME, ASSISTANT_NAME,
@@ -7,6 +8,7 @@ import {
CLAUDE_SERVICE_ID, CLAUDE_SERVICE_ID,
CODEX_MAIN_SERVICE_ID, CODEX_MAIN_SERVICE_ID,
CODEX_REVIEW_SERVICE_ID, CODEX_REVIEW_SERVICE_ID,
DATA_DIR,
normalizeServiceId, normalizeServiceId,
OWNER_AGENT_TYPE, OWNER_AGENT_TYPE,
REVIEWER_AGENT_TYPE, REVIEWER_AGENT_TYPE,
@@ -23,29 +25,33 @@ import { logger } from './logger.js';
import { import {
type RoomModeSource, type RoomModeSource,
type RoomRegistrationSnapshot, type RoomRegistrationSnapshot,
countPendingLegacyRegisteredGroupRows,
type StoredRoomSettings, type StoredRoomSettings,
buildRegisteredGroupFromStoredSettings, buildRegisteredGroupFromStoredSettings,
buildLegacyRoomMigrationPlan,
collectRegisteredAgentTypes, collectRegisteredAgentTypes,
collectRegisteredAgentTypesForFolder, collectRegisteredAgentTypesForFolder,
collectRoomRegistrationSnapshot, collectRoomRegistrationSnapshot,
getLegacyRegisteredGroup, deleteLegacyRegisteredGroupRowsForJid,
getLegacyRegisteredGroupRows, getPendingLegacyRegisteredGroupJids as getPendingLegacyRegisteredGroupJidsFromDatabase,
getStoredRoomRowsFromDatabase, getStoredRoomRowsFromDatabase,
getStoredRoomSettingsRowFromDatabase, getStoredRoomSettingsRowFromDatabase,
inferStoredRoomCapabilityTypes,
inferOwnerAgentTypeFromRegisteredAgentTypes, inferOwnerAgentTypeFromRegisteredAgentTypes,
inferRoomModeFromRegisteredAgentTypes, inferRoomModeFromRegisteredAgentTypes,
insertStoredRoomSettings, insertStoredRoomSettings,
insertStoredRoomSettingsFromMigration,
materializeRegisteredGroupsForRoom, materializeRegisteredGroupsForRoom,
normalizeRoomModeSource, normalizeRoomModeSource,
normalizeStoredAgentType, normalizeStoredAgentType,
parseRegisteredGroupRow,
resolveStoredRoomCapabilityTypes, resolveStoredRoomCapabilityTypes,
resolveAssignedRoomFolder, resolveAssignedRoomFolder,
syncRoomRoleOverridesForRoom,
upsertRoomRoleOverride,
updateStoredRoomMetadata, updateStoredRoomMetadata,
} from './db/room-registration.js'; } from './db/room-registration.js';
import { import {
initializeDatabaseSchema, initializeDatabaseSchema,
migrateJsonStateFromFiles,
openDatabaseFromFile, openDatabaseFromFile,
openInMemoryDatabase, openInMemoryDatabase,
openPersistentDatabase, openPersistentDatabase,
@@ -119,6 +125,20 @@ import {
upsertPairedProjectInDatabase, upsertPairedProjectInDatabase,
upsertPairedWorkspaceInDatabase, upsertPairedWorkspaceInDatabase,
} from './db/paired-state.js'; } 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 { import {
getLatestTurnNumberFromDatabase, getLatestTurnNumberFromDatabase,
getPairedTurnOutputsFromDatabase, getPairedTurnOutputsFromDatabase,
@@ -138,15 +158,12 @@ import {
} from './db/service-handoffs.js'; } from './db/service-handoffs.js';
import { import {
getLastRespondingAgentTypeFromDatabase, getLastRespondingAgentTypeFromDatabase,
getLegacyRouterStateKeysFromDatabase,
getRouterStateForServiceFromDatabase, getRouterStateForServiceFromDatabase,
getRouterStateFromDatabase, getRouterStateFromDatabase,
setRouterStateForServiceInDatabase, setRouterStateForServiceInDatabase,
setRouterStateInDatabase, setRouterStateInDatabase,
} from './db/router-state.js'; } from './db/router-state.js';
import {
resolveStablePairedTaskOwnerAgentType,
resolveStableRoomRoleAgentType,
} from './db/legacy-rebuilds.js';
import { import {
deleteAllSessionsForGroupFromDatabase, deleteAllSessionsForGroupFromDatabase,
deleteSessionFromDatabase, deleteSessionFromDatabase,
@@ -219,6 +236,8 @@ export type {
MemorySourceKind, MemorySourceKind,
RecallMemoryQuery, RecallMemoryQuery,
} from './db/memories.js'; } 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 { ChatInfo } from './db/messages.js';
export type { WorkItem } from './db/work-items.js'; export type { WorkItem } from './db/work-items.js';
export type { ChannelOwnerLeaseRow } from './db/channel-owner-leases.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 { export function initDatabase(): void {
db = openPersistentDatabase(); db = openPersistentDatabase();
initializeDatabaseSchema(db); initializeDatabaseSchema(db);
syncLegacyRegisteredGroupsIntoStoredRooms(); assertNoPendingLegacyRoomMigration();
assertNoPendingLegacyJsonStateMigration();
assertNoPendingLegacyRouterStateDbMigration();
clearPairedTaskExecutionLeasesForServiceInDatabase( clearPairedTaskExecutionLeasesForServiceInDatabase(
db, db,
normalizeServiceId(SERVICE_ID), normalizeServiceId(SERVICE_ID),
); );
clearExpiredPairedTaskExecutionLeasesInDatabase(db); clearExpiredPairedTaskExecutionLeasesInDatabase(db);
migrateJsonStateFromFiles({
setRouterState,
setSession,
writeLegacyRegisteredGroupAndSyncRoomSettings,
});
} }
/** @internal - for tests only. Creates a fresh in-memory database. */ /** @internal - for tests only. Creates a fresh in-memory database. */
export function _initTestDatabase(): void { export function _initTestDatabase(): void {
db = openInMemoryDatabase(); db = openInMemoryDatabase();
initializeDatabaseSchema(db); initializeDatabaseSchema(db);
syncLegacyRegisteredGroupsIntoStoredRooms();
clearPairedTaskExecutionLeasesForServiceInDatabase( clearPairedTaskExecutionLeasesForServiceInDatabase(
db, db,
normalizeServiceId(SERVICE_ID), normalizeServiceId(SERVICE_ID),
@@ -256,7 +271,8 @@ export function _initTestDatabase(): void {
export function _initTestDatabaseFromFile(dbPath: string): void { export function _initTestDatabaseFromFile(dbPath: string): void {
db = openDatabaseFromFile(dbPath); db = openDatabaseFromFile(dbPath);
initializeDatabaseSchema(db); initializeDatabaseSchema(db);
syncLegacyRegisteredGroupsIntoStoredRooms(); assertNoPendingLegacyRoomMigration();
assertNoPendingLegacyRouterStateDbMigration();
clearPairedTaskExecutionLeasesForServiceInDatabase( clearPairedTaskExecutionLeasesForServiceInDatabase(
db, db,
normalizeServiceId(SERVICE_ID), normalizeServiceId(SERVICE_ID),
@@ -278,6 +294,15 @@ export function _setStoredRoomOwnerAgentTypeForTests(
updated_at = ? updated_at = ?
WHERE chat_jid = ?`, WHERE chat_jid = ?`,
).run(ownerAgentType, new Date().toISOString(), chatJid); ).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. */ /** @internal - for tests only. */
@@ -733,7 +758,7 @@ export function getRegisteredGroup(
requestedAgentType, requestedAgentType,
); );
} }
return getLegacyRegisteredGroup(db, jid, agentType); return undefined;
} }
function writeLegacyRegisteredGroupAndSyncRoomSettings( function writeLegacyRegisteredGroupAndSyncRoomSettings(
@@ -792,6 +817,14 @@ function writeLegacyRegisteredGroupAndSyncRoomSettings(
seededAgentType === ownerAgentType ? group.agentConfig : undefined, seededAgentType === ownerAgentType ? group.agentConfig : undefined,
group.added_at, group.added_at,
); );
syncRoomRoleOverridesForRoom(
db,
jid,
roomMode,
ownerAgentType,
seededAgentType === ownerAgentType ? group.agentConfig : undefined,
group.added_at,
);
}); });
tx(); tx();
} }
@@ -807,6 +840,34 @@ export function _setRegisteredGroupForTests(
writeLegacyRegisteredGroupAndSyncRoomSettings(jid, group); 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( export function assignRoom(
chatJid: string, chatJid: string,
input: AssignRoomInput, input: AssignRoomInput,
@@ -863,15 +924,15 @@ export function assignRoom(
insertStoredRoomSettings(db, chatJid, roomMode, 'explicit', snapshot); insertStoredRoomSettings(db, chatJid, roomMode, 'explicit', snapshot);
} }
materializeRegisteredGroupsForRoom( syncRoomRoleOverridesForRoom(
db, db,
chatJid, chatJid,
snapshot,
roomMode, roomMode,
ownerAgentType, ownerAgentType,
input.ownerAgentConfig, input.ownerAgentConfig,
input.addedAt ?? now, input.addedAt ?? now,
); );
deleteLegacyRegisteredGroupRowsForJid(db, chatJid);
})(); })();
return getRegisteredGroup(chatJid); return getRegisteredGroup(chatJid);
@@ -894,13 +955,7 @@ export function updateRegisteredGroupName(jid: string, name: string): void {
plan.snapshot, plan.snapshot,
); );
} }
materializeRegisteredGroupsForRoom( deleteLegacyRegisteredGroupRowsForJid(db, jid);
db,
jid,
plan.snapshot,
plan.roomMode,
plan.snapshot.ownerAgentType,
);
})(); })();
} }
@@ -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; return result;
} }
@@ -997,23 +1043,42 @@ function syncStoredRoomRegistrationSnapshotForJid(chatJid: string): void {
updateStoredRoomMetadata(db, chatJid, snapshot); updateStoredRoomMetadata(db, chatJid, snapshot);
} }
function syncLegacyRegisteredGroupsIntoStoredRooms(): void { function assertNoPendingLegacyRoomMigration(): void {
const rows = db const pendingLegacyRows = countPendingLegacyRegisteredGroupRows(db);
.prepare( const hasLegacyRegisteredGroupsJson = fs.existsSync(
`SELECT DISTINCT jid path.join(DATA_DIR, 'registered_groups.json'),
FROM registered_groups );
ORDER BY jid`, if (pendingLegacyRows === 0 && !hasLegacyRegisteredGroupsJson) {
)
.all() as Array<{ jid: string }>;
if (rows.length === 0) {
return; return;
} }
db.transaction((registeredGroupRows: Array<{ jid: string }>) => { throw new Error(
for (const row of registeredGroupRows) { `Legacy room migration required before startup (pending_rows=${pendingLegacyRows}, legacy_json=${hasLegacyRegisteredGroupsJson})`,
syncStoredRoomRegistrationSnapshotForJid(row.jid); );
} }
})(rows);
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( function buildRoomRegistrationSnapshotFromStoredRoom(
@@ -1103,12 +1168,17 @@ export function getExplicitRoomMode(chatJid: string): RoomMode | undefined {
export function setExplicitRoomMode(chatJid: string, roomMode: RoomMode): void { export function setExplicitRoomMode(chatJid: string, roomMode: RoomMode): void {
upsertStoredRoomMode(chatJid, roomMode, 'explicit'); upsertStoredRoomMode(chatJid, roomMode, 'explicit');
deleteLegacyRegisteredGroupRowsForJid(db, chatJid);
} }
export function clearExplicitRoomMode(chatJid: string): void { 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) { if (agentTypes.length === 0) {
db.prepare('DELETE FROM room_settings WHERE chat_jid = ?').run(chatJid); db.prepare('DELETE FROM room_settings WHERE chat_jid = ?').run(chatJid);
deleteLegacyRegisteredGroupRowsForJid(db, chatJid);
return; return;
} }
upsertStoredRoomMode( upsertStoredRoomMode(
@@ -1116,6 +1186,7 @@ export function clearExplicitRoomMode(chatJid: string): void {
inferRoomModeFromRegisteredAgentTypes(agentTypes), inferRoomModeFromRegisteredAgentTypes(agentTypes),
'inferred', 'inferred',
); );
deleteLegacyRegisteredGroupRowsForJid(db, chatJid);
} }
export function getEffectiveRoomMode(chatJid: string): RoomMode { export function getEffectiveRoomMode(chatJid: string): RoomMode {
@@ -1232,6 +1303,44 @@ export function refreshPairedTaskExecutionLease(args: {
return refreshPairedTaskExecutionLeaseInDatabase(db, 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 { export function upsertPairedWorkspace(workspace: PairedWorkspace): void {
upsertPairedWorkspaceInDatabase(db, workspace); upsertPairedWorkspaceInDatabase(db, workspace);
} }
@@ -1254,6 +1363,8 @@ export function _clearPairedTurnReservationsForTests(): void {
} }
clearPairedTurnReservationsInDatabase(db); clearPairedTurnReservationsInDatabase(db);
clearPairedTaskExecutionLeasesInDatabase(db); clearPairedTaskExecutionLeasesInDatabase(db);
clearPairedTurnAttemptsInDatabase(db);
clearPairedTurnsInDatabase(db);
} }
/** /**

View File

@@ -178,6 +178,10 @@ export function applyBaseSchema(database: Database): void {
task_status TEXT NOT NULL, task_status TEXT NOT NULL,
round_trip_count INTEGER NOT NULL DEFAULT 0, round_trip_count INTEGER NOT NULL DEFAULT 0,
task_updated_at TEXT NOT NULL, 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, intent_kind TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending', status TEXT NOT NULL DEFAULT 'pending',
scheduled_run_id TEXT, scheduled_run_id TEXT,
@@ -186,7 +190,11 @@ export function applyBaseSchema(database: Database): void {
updated_at TEXT NOT NULL, updated_at TEXT NOT NULL,
consumed_at TEXT, consumed_at TEXT,
PRIMARY KEY (chat_jid, task_id, task_updated_at, intent_kind), 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 (status IN ('pending', 'completed')),
CHECK (turn_role IN ('owner', 'reviewer', 'arbiter')),
CHECK ( CHECK (
intent_kind IN ( intent_kind IN (
'owner-turn', 'owner-turn',
@@ -199,10 +207,89 @@ export function applyBaseSchema(database: Database): void {
); );
CREATE INDEX IF NOT EXISTS idx_paired_turn_reservations_task CREATE INDEX IF NOT EXISTS idx_paired_turn_reservations_task
ON paired_turn_reservations(task_id, task_updated_at, status); 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 ( CREATE TABLE IF NOT EXISTS paired_task_execution_leases (
task_id TEXT PRIMARY KEY, task_id TEXT PRIMARY KEY,
chat_jid TEXT NOT NULL, chat_jid TEXT NOT NULL,
role 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, intent_kind TEXT NOT NULL,
claimed_run_id TEXT NOT NULL, claimed_run_id TEXT NOT NULL,
claimed_service_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, claimed_at TEXT NOT NULL,
updated_at TEXT NOT NULL, updated_at TEXT NOT NULL,
expires_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 (role IN ('owner', 'reviewer', 'arbiter')),
CHECK ( CHECK (
intent_kind IN ( intent_kind IN (
@@ -249,16 +339,35 @@ export function applyBaseSchema(database: Database): void {
is_main INTEGER DEFAULT 0, is_main INTEGER DEFAULT 0,
owner_agent_type TEXT, owner_agent_type TEXT,
work_dir TEXT, work_dir TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL, updated_at TEXT NOT NULL,
CHECK (room_mode IN ('single', 'tribunal')), CHECK (room_mode IN ('single', 'tribunal')),
CHECK (owner_agent_type IN ('claude-code', 'codex') OR owner_agent_type IS NULL) 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 ( CREATE TABLE IF NOT EXISTS service_handoffs (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
chat_jid TEXT NOT NULL, chat_jid TEXT NOT NULL,
group_folder TEXT NOT NULL, group_folder TEXT NOT NULL,
source_service_id TEXT NOT NULL, source_service_id TEXT NOT NULL,
target_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_role TEXT,
source_agent_type TEXT, source_agent_type TEXT,
target_role TEXT, target_role TEXT,
@@ -273,8 +382,21 @@ export function applyBaseSchema(database: Database): void {
claimed_at TEXT, claimed_at TEXT,
completed_at TEXT, completed_at TEXT,
last_error 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 (status IN ('pending', 'claimed', 'completed', 'failed')),
CHECK (intended_role IN ('owner', 'reviewer', 'arbiter') OR intended_role IS NULL), 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 (source_role IN ('owner', 'reviewer', 'arbiter') OR source_role IS NULL),
CHECK (target_role IN ('owner', 'reviewer', 'arbiter') OR target_role IS NULL) CHECK (target_role IN ('owner', 'reviewer', 'arbiter') OR target_role IS NULL)
); );

View File

@@ -2,27 +2,41 @@ import { Database } from 'bun:sqlite';
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
import { ASSISTANT_NAME, DATA_DIR, STORE_DIR } from '../config.js'; import { ASSISTANT_NAME, STORE_DIR } from '../config.js';
import { logger } from '../logger.js';
import type { RegisteredGroup } from '../types.js';
import { readJsonFile } from '../utils.js';
import { applyBaseSchema } from './base-schema.js'; import { applyBaseSchema } from './base-schema.js';
import { applySchemaMigrations } from './schema.js'; import { applySchemaMigrations } from './schema.js';
export interface JsonStateMigrationHooks { function setForeignKeys(database: Database, enabled: boolean): void {
setRouterState(key: string, value: string): void; database.exec(`PRAGMA foreign_keys = ${enabled ? 'ON' : 'OFF'}`);
setSession(groupFolder: string, sessionId: string): void; }
writeLegacyRegisteredGroupAndSyncRoomSettings(
jid: string, function assertNoForeignKeyViolations(database: Database): void {
group: RegisteredGroup, const violations = database
): void; .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 { export function initializeDatabaseSchema(database: Database): void {
setForeignKeys(database, false);
applyBaseSchema(database); applyBaseSchema(database);
applySchemaMigrations(database, { applySchemaMigrations(database, {
assistantName: ASSISTANT_NAME, assistantName: ASSISTANT_NAME,
}); });
setForeignKeys(database, true);
assertNoForeignKeyViolations(database);
} }
export function openPersistentDatabase(): Database { export function openPersistentDatabase(): Database {
@@ -42,62 +56,3 @@ export function openInMemoryDatabase(): Database {
export function openDatabaseFromFile(dbPath: string): Database { export function openDatabaseFromFile(dbPath: string): Database {
return new Database(dbPath); 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<string, string>;
} | 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',
);
}
}
}
}

View File

@@ -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;
}
}

View File

@@ -1,17 +1,7 @@
import { Database } from 'bun:sqlite'; 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 { AgentType } from '../types.js';
import { resolveStableReviewerAgentType } from './legacy-rebuilds.js'; import { canonicalizeChannelOwnerLeaseMetadata } from './canonical-role-metadata.js';
import { normalizeStoredAgentType } from './room-registration.js';
export interface ChannelOwnerLeaseRow { export interface ChannelOwnerLeaseRow {
chat_jid: string; chat_jid: string;
@@ -52,55 +42,28 @@ export interface SetChannelOwnerLeaseInput {
function hydrateChannelOwnerLeaseRow( function hydrateChannelOwnerLeaseRow(
row: StoredChannelOwnerLeaseRow, row: StoredChannelOwnerLeaseRow,
): ChannelOwnerLeaseRow { ): ChannelOwnerLeaseRow {
const persistedOwnerAgentType = normalizeStoredAgentType( const {
row.owner_agent_type, ownerAgentType,
); reviewerAgentType,
const persistedReviewerAgentType = normalizeStoredAgentType( arbiterAgentType,
row.reviewer_agent_type, ownerServiceId,
); reviewerServiceId,
const persistedArbiterAgentType = normalizeStoredAgentType( arbiterServiceId,
row.arbiter_agent_type, } = canonicalizeChannelOwnerLeaseMetadata({
); chat_jid: row.chat_jid,
const ownerAgentType = owner_service_id: row.owner_service_id,
persistedOwnerAgentType ?? reviewer_service_id: row.reviewer_service_id,
(row.owner_service_id arbiter_service_id: row.arbiter_service_id,
? inferAgentTypeFromServiceShadow(row.owner_service_id) owner_agent_type: row.owner_agent_type,
: undefined) ?? reviewer_agent_type: row.reviewer_agent_type,
OWNER_AGENT_TYPE; arbiter_agent_type: row.arbiter_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);
return { return {
chat_jid: row.chat_jid, chat_jid: row.chat_jid,
owner_service_id: owner_service_id: ownerServiceId,
row.owner_service_id ?? reviewer_service_id: reviewerServiceId,
resolveRoleServiceShadow('owner', ownerAgentType) ?? arbiter_service_id: arbiterServiceId,
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_agent_type: ownerAgentType, owner_agent_type: ownerAgentType,
reviewer_agent_type: reviewerAgentType, reviewer_agent_type: reviewerAgentType,
arbiter_agent_type: arbiterAgentType, arbiter_agent_type: arbiterAgentType,
@@ -132,41 +95,22 @@ export function setChannelOwnerLeaseInDatabase(
database: Database, database: Database,
input: SetChannelOwnerLeaseInput, input: SetChannelOwnerLeaseInput,
): void { ): void {
const ownerAgentType = const {
normalizeStoredAgentType(input.owner_agent_type) ?? ownerAgentType,
inferAgentTypeFromServiceShadow(input.owner_service_id) ?? reviewerAgentType,
OWNER_AGENT_TYPE; arbiterAgentType,
const reviewerAgentType = ownerServiceId,
input.reviewer_service_id == null && input.reviewer_agent_type == null reviewerServiceId,
? null arbiterServiceId,
: (normalizeStoredAgentType(input.reviewer_agent_type) ?? } = canonicalizeChannelOwnerLeaseMetadata({
inferAgentTypeFromServiceShadow(input.reviewer_service_id ?? null) ?? chat_jid: input.chat_jid,
resolveStableReviewerAgentType(ownerAgentType, null)); owner_service_id: input.owner_service_id,
const arbiterAgentType = reviewer_service_id: input.reviewer_service_id,
input.arbiter_service_id == null && input.arbiter_agent_type == null arbiter_service_id: input.arbiter_service_id,
? null owner_agent_type: input.owner_agent_type,
: (normalizeStoredAgentType(input.arbiter_agent_type) ?? reviewer_agent_type: input.reviewer_agent_type,
inferAgentTypeFromServiceShadow(input.arbiter_service_id ?? null) ?? arbiter_agent_type: input.arbiter_agent_type,
ARBITER_AGENT_TYPE ?? });
null);
const ownerServiceId =
input.owner_service_id ??
resolveRoleServiceShadow('owner', ownerAgentType) ??
CLAUDE_SERVICE_ID;
const reviewerServiceId =
input.reviewer_service_id == null && reviewerAgentType == null
? null
: (input.reviewer_service_id ??
(reviewerAgentType == null
? null
: resolveRoleServiceShadow('reviewer', reviewerAgentType)));
const arbiterServiceId =
input.arbiter_service_id == null && arbiterAgentType == null
? null
: (input.arbiter_service_id ??
(arbiterAgentType == null
? null
: resolveRoleServiceShadow('arbiter', arbiterAgentType)));
database database
.prepare( .prepare(

View File

@@ -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;
}

View File

@@ -1,21 +1,15 @@
import { Database } from 'bun:sqlite'; import { Database } from 'bun:sqlite';
import { SERVICE_ID, normalizeServiceId } from '../config.js';
import { import {
ARBITER_AGENT_TYPE, buildPairedTurnIdentity,
CODEX_MAIN_SERVICE_ID, resolvePairedTurnRole,
CODEX_REVIEW_SERVICE_ID, } from '../paired-turn-identity.js';
SERVICE_ID, import { canonicalizePairedTaskMetadata } from './canonical-role-metadata.js';
normalizeServiceId,
} from '../config.js';
import { import {
resolveStablePairedTaskOwnerAgentType, ensurePairedTurnQueuedInDatabase,
resolveStableReviewerAgentType, markPairedTurnRunningInDatabase,
} from './legacy-rebuilds.js'; } from './paired-turns.js';
import { normalizeStoredAgentType } from './room-registration.js';
import {
inferAgentTypeFromServiceShadow,
resolveRoleServiceShadow,
} from '../role-service-shadow.js';
import { import {
AgentType, AgentType,
PairedProject, PairedProject,
@@ -66,69 +60,31 @@ function computeExecutionLeaseExpiry(now: string): string {
).toISOString(); ).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( function hydratePairedTaskRow(
database: Database, database: Database,
row: StoredPairedTaskRow, row: StoredPairedTaskRow,
): PairedTask { ): PairedTask {
const persistedOwnerAgentType = normalizeStoredAgentType( const {
row.owner_agent_type ?? null, ownerAgentType,
); reviewerAgentType,
const persistedReviewerAgentType = normalizeStoredAgentType( arbiterAgentType,
row.reviewer_agent_type ?? null, ownerServiceId,
); reviewerServiceId,
const stableOwnerAgentType = resolveStablePairedTaskOwnerAgentType( } = canonicalizePairedTaskMetadata({
database, id: row.id,
row, owner_service_id: row.owner_service_id,
); reviewer_service_id: row.reviewer_service_id,
const ownerAgentType = owner_agent_type: row.owner_agent_type,
stableOwnerAgentType ?? reviewer_agent_type: row.reviewer_agent_type,
(row.owner_service_id arbiter_agent_type: row.arbiter_agent_type,
? 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;
return { return {
...row, ...row,
owner_service_id: owner_service_id: ownerServiceId,
row.owner_service_id ?? reviewer_service_id: reviewerServiceId,
resolveRoleServiceShadow('owner', ownerAgentType) ?? owner_agent_type: ownerAgentType,
CODEX_MAIN_SERVICE_ID, reviewer_agent_type: reviewerAgentType,
reviewer_service_id:
row.reviewer_service_id ??
resolveRoleServiceShadow('reviewer', reviewerAgentType) ??
CODEX_REVIEW_SERVICE_ID,
owner_agent_type: ownerAgentType ?? null,
reviewer_agent_type: reviewerAgentType ?? null,
arbiter_agent_type: arbiterAgentType, arbiter_agent_type: arbiterAgentType,
}; };
} }
@@ -176,26 +132,20 @@ export function createPairedTaskInDatabase(
database: Database, database: Database,
task: PairedTask, task: PairedTask,
): void { ): void {
const ownerAgentType = const {
normalizeStoredAgentType(task.owner_agent_type) ?? ownerAgentType,
inferAgentTypeFromServiceShadow(task.owner_service_id) ?? reviewerAgentType,
null; arbiterAgentType,
const reviewerAgentType = ownerServiceId,
normalizeStoredAgentType(task.reviewer_agent_type) ?? reviewerServiceId,
inferAgentTypeFromServiceShadow(task.reviewer_service_id) ?? } = canonicalizePairedTaskMetadata({
resolveStableReviewerAgentType(ownerAgentType ?? undefined, null); id: task.id,
const arbiterAgentType = owner_service_id: task.owner_service_id,
normalizeStoredAgentType(task.arbiter_agent_type) ?? reviewer_service_id: task.reviewer_service_id,
ARBITER_AGENT_TYPE ?? owner_agent_type: task.owner_agent_type,
null; reviewer_agent_type: task.reviewer_agent_type,
const ownerServiceId = arbiter_agent_type: task.arbiter_agent_type,
task.owner_service_id ?? });
resolveRoleServiceShadow('owner', ownerAgentType) ??
CODEX_MAIN_SERVICE_ID;
const reviewerServiceId =
task.reviewer_service_id ??
resolveRoleServiceShadow('reviewer', reviewerAgentType) ??
CODEX_REVIEW_SERVICE_ID;
database database
.prepare( .prepare(
@@ -426,6 +376,12 @@ export function reservePairedTurnReservationInDatabase(
}, },
): boolean { ): boolean {
const now = new Date().toISOString(); const now = new Date().toISOString();
const turnIdentity = buildPairedTurnIdentity({
taskId: args.taskId,
taskUpdatedAt: args.taskUpdatedAt,
intentKind: args.intentKind,
role: resolvePairedTurnRole(args.intentKind),
});
const result = database const result = database
.prepare( .prepare(
` `
@@ -435,6 +391,10 @@ export function reservePairedTurnReservationInDatabase(
task_status, task_status,
round_trip_count, round_trip_count,
task_updated_at, task_updated_at,
turn_id,
turn_attempt_id,
turn_attempt_no,
turn_role,
intent_kind, intent_kind,
status, status,
scheduled_run_id, scheduled_run_id,
@@ -443,8 +403,32 @@ export function reservePairedTurnReservationInDatabase(
updated_at, updated_at,
consumed_at consumed_at
) )
VALUES (?, ?, ?, ?, ?, ?, 'pending', ?, NULL, ?, ?, NULL) VALUES (?, ?, ?, ?, ?, ?, NULL, NULL, ?, ?, 'pending', ?, NULL, ?, ?, NULL)
ON CONFLICT(chat_jid, task_id, task_updated_at, intent_kind) DO NOTHING 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( .run(
@@ -453,13 +437,20 @@ export function reservePairedTurnReservationInDatabase(
args.taskStatus, args.taskStatus,
args.roundTripCount, args.roundTripCount,
args.taskUpdatedAt, args.taskUpdatedAt,
turnIdentity.turnId,
turnIdentity.role,
args.intentKind, args.intentKind,
args.runId, args.runId,
now, now,
now, now,
); );
return result.changes > 0; if (result.changes > 0) {
ensurePairedTurnQueuedInDatabase(database, turnIdentity);
return true;
}
return false;
} }
class PairedTurnReservationClaimError extends Error {} class PairedTurnReservationClaimError extends Error {}
@@ -479,7 +470,12 @@ export function claimPairedTurnReservationInDatabase(
const tx = database.transaction(() => { const tx = database.transaction(() => {
const now = new Date().toISOString(); const now = new Date().toISOString();
const expiresAt = computeExecutionLeaseExpiry(now); 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 const existingLease = database
.prepare( .prepare(
` `
@@ -505,6 +501,9 @@ export function claimPairedTurnReservationInDatabase(
task_id, task_id,
chat_jid, chat_jid,
role, role,
turn_id,
turn_attempt_id,
turn_attempt_no,
intent_kind, intent_kind,
claimed_run_id, claimed_run_id,
claimed_service_id, claimed_service_id,
@@ -514,13 +513,14 @@ export function claimPairedTurnReservationInDatabase(
updated_at, updated_at,
expires_at expires_at
) )
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, NULL, NULL, ?, ?, ?, ?, ?, ?, ?, ?)
`, `,
) )
.run( .run(
args.taskId, args.taskId,
args.chatJid, args.chatJid,
leaseRole, turnIdentity.role,
turnIdentity.turnId,
args.intentKind, args.intentKind,
args.runId, args.runId,
CURRENT_SERVICE_ID, CURRENT_SERVICE_ID,
@@ -543,6 +543,9 @@ export function claimPairedTurnReservationInDatabase(
UPDATE paired_task_execution_leases UPDATE paired_task_execution_leases
SET chat_jid = ?, SET chat_jid = ?,
role = ?, role = ?,
turn_id = ?,
turn_attempt_id = NULL,
turn_attempt_no = NULL,
intent_kind = ?, intent_kind = ?,
claimed_run_id = ?, claimed_run_id = ?,
claimed_service_id = ?, claimed_service_id = ?,
@@ -559,7 +562,8 @@ export function claimPairedTurnReservationInDatabase(
) )
.run( .run(
args.chatJid, args.chatJid,
leaseRole, turnIdentity.role,
turnIdentity.turnId,
args.intentKind, args.intentKind,
args.runId, args.runId,
CURRENT_SERVICE_ID, 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 const claimedTask = database
.prepare( .prepare(
` `
@@ -619,6 +598,67 @@ export function claimPairedTurnReservationInDatabase(
if (claimedTask.changes === 0) { if (claimedTask.changes === 0) {
throw new PairedTurnReservationClaimError(); 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 { try {

View File

@@ -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();
}

515
src/db/paired-turns.ts Normal file
View File

@@ -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();
}

View File

@@ -47,6 +47,31 @@ export interface RegisteredGroupDatabaseRow {
work_dir: string | null; 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( export function normalizeRoomModeSource(
source: string | null | undefined, source: string | null | undefined,
): RoomModeSource | undefined { ): RoomModeSource | undefined {
@@ -196,7 +221,132 @@ export function getLegacyRegisteredGroup(
return parseRegisteredGroupRow(row); 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, database: Database,
jid: string, jid: string,
preferredAgentType?: AgentType, 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( export function getStoredRoomSettingsRowFromDatabase(
database: Database, database: Database,
chatJid: string, chatJid: string,
@@ -300,14 +574,16 @@ export function buildRegisteredGroupFromStoredSettings(
requestedAgentType?: AgentType, requestedAgentType?: AgentType,
): (RegisteredGroup & { jid: string }) | undefined { ): (RegisteredGroup & { jid: string }) | undefined {
const capabilityTypes = resolveStoredRoomCapabilityTypes(database, stored); const capabilityTypes = resolveStoredRoomCapabilityTypes(database, stored);
const ownerAgentType =
stored.ownerAgentType ||
(capabilityTypes.length > 0
? inferOwnerAgentTypeFromRegisteredAgentTypes(capabilityTypes)
: undefined);
const resolvedAgentType = requestedAgentType const resolvedAgentType = requestedAgentType
? capabilityTypes.includes(requestedAgentType) ? capabilityTypes.includes(requestedAgentType)
? requestedAgentType ? requestedAgentType
: undefined : undefined
: stored.ownerAgentType || : ownerAgentType;
(capabilityTypes.length > 0
? inferOwnerAgentTypeFromRegisteredAgentTypes(capabilityTypes)
: undefined);
if (!resolvedAgentType) return undefined; if (!resolvedAgentType) return undefined;
if (!stored.folder || !isValidGroupFolder(stored.folder)) { if (!stored.folder || !isValidGroupFolder(stored.folder)) {
@@ -318,18 +594,19 @@ export function buildRegisteredGroupFromStoredSettings(
return undefined; return undefined;
} }
const capabilityMetadata = getRegisteredGroupCapabilityMetadata( const role: 'owner' | 'reviewer' =
ownerAgentType === resolvedAgentType ? 'owner' : 'reviewer';
const capabilityMetadata = getStoredRoomRoleOverrideRows(
database, database,
stored.chatJid, stored.chatJid,
resolvedAgentType, ).get(role);
);
return { return {
jid: stored.chatJid, jid: stored.chatJid,
name: stored.name || stored.chatJid, name: stored.name || stored.chatJid,
folder: stored.folder, folder: stored.folder,
trigger: stored.trigger || `@${ASSISTANT_NAME}`, 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, agentConfig: capabilityMetadata?.agentConfig,
requiresTrigger: stored.requiresTrigger, requiresTrigger: stored.requiresTrigger,
isMain: stored.isMain ? true : undefined, 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<AgentType>();
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( export function resolveStoredRoomCapabilityTypes(
database: Database, database: Database,
stored: StoredRoomSettings, stored: StoredRoomSettings,
): AgentType[] { ): 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') { 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<AgentType>([ownerAgentType]); const types = new Set<AgentType>([ownerAgentType]);
if (stored.roomMode === 'tribunal') { 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]; return [...types];
} }
@@ -659,6 +956,7 @@ export function insertStoredRoomSettings(
source: RoomModeSource, source: RoomModeSource,
snapshot: RoomRegistrationSnapshot, snapshot: RoomRegistrationSnapshot,
): void { ): void {
const now = new Date().toISOString();
database database
.prepare( .prepare(
`INSERT INTO room_settings ( `INSERT INTO room_settings (
@@ -672,8 +970,9 @@ export function insertStoredRoomSettings(
is_main, is_main,
owner_agent_type, owner_agent_type,
work_dir, work_dir,
created_at,
updated_at updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
) )
.run( .run(
chatJid, chatJid,
@@ -686,10 +985,150 @@ export function insertStoredRoomSettings(
snapshot.isMain ? 1 : 0, snapshot.isMain ? 1 : 0,
snapshot.ownerAgentType, snapshot.ownerAgentType,
snapshot.workDir, 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( export function updateStoredRoomMetadata(
database: Database, database: Database,
chatJid: string, chatJid: string,

View File

@@ -65,6 +65,23 @@ export function setRouterStateForServiceInDatabase(
.run(prefixedKey, value); .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( export function getLastRespondingAgentTypeFromDatabase(
database: Database, database: Database,
chatJid: string, chatJid: string,

File diff suppressed because it is too large Load Diff

View File

@@ -5,14 +5,21 @@ import {
SERVICE_ID, SERVICE_ID,
SERVICE_SESSION_SCOPE, SERVICE_SESSION_SCOPE,
} from '../config.js'; } 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 { AgentType, PairedRoomRole } from '../types.js';
import { setPairedTurnAttemptContinuationHandoffIdInDatabase } from './paired-turn-attempts.js';
import {
failPairedTurnInDatabase,
markPairedTurnDelegatedInDatabase,
} from './paired-turns.js';
import { import {
getLatestMessageSeqAtOrBeforeFromDatabase, getLatestMessageSeqAtOrBeforeFromDatabase,
normalizeSeqCursor, normalizeSeqCursor,
} from './messages.js'; } from './messages.js';
import { resolveStableRoomRoleAgentType } from './legacy-rebuilds.js'; import { canonicalizeServiceHandoffMetadata } from './canonical-role-metadata.js';
import { normalizeStoredAgentType } from './room-registration.js';
import { import {
getRouterStateFromDatabase, getRouterStateFromDatabase,
setRouterStateInDatabase, setRouterStateInDatabase,
@@ -22,6 +29,13 @@ export interface ServiceHandoff {
id: number; id: number;
chat_jid: string; chat_jid: string;
group_folder: 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; source_service_id: string;
target_service_id: string; target_service_id: string;
source_role: PairedRoomRole | null; source_role: PairedRoomRole | null;
@@ -43,6 +57,12 @@ export interface ServiceHandoff {
export interface CreateServiceHandoffInput { export interface CreateServiceHandoffInput {
chat_jid: string; chat_jid: string;
group_folder: 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; source_service_id?: string;
target_service_id?: string; target_service_id?: string;
source_role?: PairedRoomRole | null; source_role?: PairedRoomRole | null;
@@ -76,46 +96,72 @@ interface StoredServiceHandoffRow extends Omit<
target_agent_type: string; 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( function hydrateServiceHandoffRow(
database: Database,
row: StoredServiceHandoffRow, row: StoredServiceHandoffRow,
): ServiceHandoff { ): ServiceHandoff {
const sourceAgentType = const {
normalizeStoredAgentType(row.source_agent_type) ?? sourceRole,
(row.source_role targetRole,
? resolveStableRoomRoleAgentType(database, { sourceAgentType,
chatJid: row.chat_jid, targetAgentType,
groupFolder: row.group_folder, sourceServiceId,
role: row.source_role, targetServiceId,
}) } = canonicalizeServiceHandoffMetadata({
: null); id: row.id,
const targetAgentType = chat_jid: row.chat_jid,
normalizeStoredAgentType(row.target_agent_type) ?? source_service_id: row.source_service_id,
(row.target_role target_service_id: row.target_service_id,
? resolveStableRoomRoleAgentType(database, { source_role: row.source_role,
chatJid: row.chat_jid, target_role: row.target_role,
groupFolder: row.group_folder, intended_role: row.intended_role,
role: row.target_role, source_agent_type: row.source_agent_type,
}) target_agent_type: row.target_agent_type,
: null) ?? });
'claude-code'; const turnIdentity = hydrateStoredTurnIdentity(row);
return { return {
...row, ...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, source_agent_type: sourceAgentType ?? null,
target_agent_type: targetAgentType, target_agent_type: targetAgentType,
source_service_id: source_service_id: sourceServiceId,
row.source_service_id ?? target_service_id: targetServiceId,
(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),
}; };
} }
@@ -184,35 +230,66 @@ export function createServiceHandoffInDatabase(
database: Database, database: Database,
input: CreateServiceHandoffInput, input: CreateServiceHandoffInput,
): ServiceHandoff { ): ServiceHandoff {
const sourceRole = input.source_role ?? input.intended_role ?? null; const turnIdentity =
const targetRole = input.target_role ?? input.intended_role ?? null; input.paired_task_id &&
const sourceAgentType = input.paired_task_updated_at &&
normalizeStoredAgentType(input.source_agent_type) ?? input.turn_intent_kind
(sourceRole ? buildPairedTurnIdentity({
? resolveStableRoomRoleAgentType(database, { taskId: input.paired_task_id,
chatJid: input.chat_jid, taskUpdatedAt: input.paired_task_updated_at,
groupFolder: input.group_folder, intentKind: input.turn_intent_kind,
role: sourceRole, role: input.turn_role ?? undefined,
turnId: input.turn_id ?? undefined,
}) })
: null); : null;
const sourceServiceId = const {
input.source_service_id ?? sourceRole,
(sourceRole != null targetRole,
? (resolveRoleServiceShadow(sourceRole, sourceAgentType) ?? null) sourceAgentType,
: null) ?? targetAgentType,
SERVICE_SESSION_SCOPE; sourceServiceId,
const targetServiceId = targetServiceId,
input.target_service_id ?? } = canonicalizeServiceHandoffMetadata({
(targetRole != null id: 'new',
? (resolveRoleServiceShadow(targetRole, input.target_agent_type) ?? null) chat_jid: input.chat_jid,
: null) ?? source_service_id: input.source_service_id,
SERVICE_SESSION_SCOPE; 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 database
.prepare( .prepare(
`INSERT INTO service_handoffs ( `INSERT INTO service_handoffs (
chat_jid, chat_jid,
group_folder, 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, source_service_id,
target_service_id, target_service_id,
source_role, source_role,
@@ -224,17 +301,24 @@ export function createServiceHandoffInDatabase(
end_seq, end_seq,
reason, reason,
intended_role intended_role
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
) )
.run( .run(
input.chat_jid, input.chat_jid,
input.group_folder, input.group_folder,
turnIdentity?.taskId ?? null,
turnIdentity?.taskUpdatedAt ?? null,
turnIdentity?.turnId ?? null,
turnAttemptId,
turnAttemptNo,
turnIdentity?.intentKind ?? null,
turnIdentity?.role ?? null,
sourceServiceId, sourceServiceId,
targetServiceId, targetServiceId,
sourceRole, sourceRole,
sourceAgentType ?? null, sourceAgentType ?? null,
targetRole, targetRole,
input.target_agent_type, targetAgentType,
input.prompt, input.prompt,
input.start_seq ?? null, input.start_seq ?? null,
input.end_seq ?? null, input.end_seq ?? null,
@@ -245,8 +329,14 @@ export function createServiceHandoffInDatabase(
const lastId = ( const lastId = (
database.prepare('SELECT last_insert_rowid() as id').get() as { id: number } database.prepare('SELECT last_insert_rowid() as id').get() as { id: number }
).id; ).id;
if (turnIdentity && currentAttempt) {
setPairedTurnAttemptContinuationHandoffIdInDatabase(database, {
turnId: turnIdentity.turnId,
attemptNo: currentAttempt.attempt_no,
handoffId: lastId,
});
}
return hydrateServiceHandoffRow( return hydrateServiceHandoffRow(
database,
database database
.prepare('SELECT * FROM service_handoffs WHERE id = ?') .prepare('SELECT * FROM service_handoffs WHERE id = ?')
.get(lastId) as StoredServiceHandoffRow, .get(lastId) as StoredServiceHandoffRow,
@@ -258,7 +348,7 @@ export function getPendingServiceHandoffsFromDatabase(
targetServiceId: string = SERVICE_SESSION_SCOPE, targetServiceId: string = SERVICE_SESSION_SCOPE,
): ServiceHandoff[] { ): ServiceHandoff[] {
return getPendingServiceHandoffRows(database) return getPendingServiceHandoffRows(database)
.map((row) => hydrateServiceHandoffRow(database, row)) .map((row) => hydrateServiceHandoffRow(row))
.filter( .filter(
(handoff) => (handoff) =>
normalizeServiceId(handoff.target_service_id) === normalizeServiceId(handoff.target_service_id) ===
@@ -270,7 +360,7 @@ export function getAllPendingServiceHandoffsFromDatabase(
database: Database, database: Database,
): ServiceHandoff[] { ): ServiceHandoff[] {
return getPendingServiceHandoffRows(database).map((row) => return getPendingServiceHandoffRows(database).map((row) =>
hydrateServiceHandoffRow(database, row), hydrateServiceHandoffRow(row),
); );
} }
@@ -312,6 +402,19 @@ export function failServiceHandoffInDatabase(
id: number, id: number,
error: string, error: string,
): void { ): void {
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 database
.prepare( .prepare(
`UPDATE service_handoffs `UPDATE service_handoffs
@@ -321,6 +424,14 @@ export function failServiceHandoffInDatabase(
WHERE id = ?`, WHERE id = ?`,
) )
.run(error, id); .run(error, id);
if (turnIdentity) {
failPairedTurnInDatabase(database, {
turnIdentity,
error,
});
}
})();
} }
export function completeServiceHandoffAndAdvanceTargetCursorInDatabase( export function completeServiceHandoffAndAdvanceTargetCursorInDatabase(

View File

@@ -168,11 +168,8 @@ const runtime = createMessageRuntime({
}); });
function loadState(): void { function loadState(): void {
lastTimestamp = normalizeStoredSeqCursor( lastTimestamp = normalizeStoredSeqCursor(getRouterState('last_seq'));
getRouterState('last_seq') || getRouterState('last_timestamp'), const agentTs = getRouterState('last_agent_seq');
);
const agentTs =
getRouterState('last_agent_seq') || getRouterState('last_agent_timestamp');
try { try {
const parsed = agentTs const parsed = agentTs
? (JSON.parse(agentTs) as Record<string, string>) ? (JSON.parse(agentTs) as Record<string, string>)

View File

@@ -1,10 +1,13 @@
import type { AgentOutput } from './agent-runner.js'; import type { AgentOutput } from './agent-runner.js';
import { import {
completePairedTurn,
failPairedTurn,
getLastHumanMessageSender, getLastHumanMessageSender,
getLatestTurnNumber, getLatestTurnNumber,
getPairedTaskById, getPairedTaskById,
insertPairedTurnOutput, insertPairedTurnOutput,
refreshPairedTaskExecutionLease, refreshPairedTaskExecutionLease,
releasePairedTaskExecutionLease,
} from './db.js'; } from './db.js';
import { logger } from './logger.js'; import { logger } from './logger.js';
import { import {
@@ -13,6 +16,7 @@ import {
} from './paired-execution-context.js'; } from './paired-execution-context.js';
import { resolvePairedFollowUpQueueAction } from './message-agent-executor-rules.js'; import { resolvePairedFollowUpQueueAction } from './message-agent-executor-rules.js';
import { enqueuePairedFollowUpAfterEvent } from './message-runtime-follow-up.js'; import { enqueuePairedFollowUpAfterEvent } from './message-runtime-follow-up.js';
import type { PairedTurnIdentity } from './paired-turn-identity.js';
import type { PairedRoomRole } from './types.js'; import type { PairedRoomRole } from './types.js';
type ExecutorLog = Pick<typeof logger, 'info' | 'warn'>; type ExecutorLog = Pick<typeof logger, 'info' | 'warn'>;
@@ -26,6 +30,7 @@ export interface PairedExecutionLifecycle {
}): void; }): void;
recordFinalOutputBeforeDelivery(outputText: string): void; recordFinalOutputBeforeDelivery(outputText: string): void;
completeImmediately(args: { status: 'succeeded' | 'failed' }): void; completeImmediately(args: { status: 'succeeded' | 'failed' }): void;
markDelegated(): void;
markStatus(status: 'succeeded' | 'failed'): void; markStatus(status: 'succeeded' | 'failed'): void;
markSawOutput(sawOutput: boolean): void; markSawOutput(sawOutput: boolean): void;
getSummary(): string | null; getSummary(): string | null;
@@ -34,6 +39,7 @@ export interface PairedExecutionLifecycle {
export function createPairedExecutionLifecycle(args: { export function createPairedExecutionLifecycle(args: {
pairedExecutionContext?: PreparedPairedExecutionContext; pairedExecutionContext?: PreparedPairedExecutionContext;
pairedTurnIdentity?: PairedTurnIdentity;
completedRole: PairedRoomRole; completedRole: PairedRoomRole;
chatJid: string; chatJid: string;
runId: string; runId: string;
@@ -43,6 +49,7 @@ export function createPairedExecutionLifecycle(args: {
}): PairedExecutionLifecycle { }): PairedExecutionLifecycle {
const { const {
pairedExecutionContext, pairedExecutionContext,
pairedTurnIdentity,
completedRole, completedRole,
chatJid, chatJid,
runId, runId,
@@ -55,10 +62,30 @@ export function createPairedExecutionLifecycle(args: {
let pairedExecutionSummary: string | null = null; let pairedExecutionSummary: string | null = null;
let pairedFinalOutput: string | null = null; let pairedFinalOutput: string | null = null;
let pairedExecutionCompleted = false; let pairedExecutionCompleted = false;
let pairedExecutionDelegated = false;
let pairedSawOutput = false; let pairedSawOutput = false;
let pairedTurnOutputPersisted = false; let pairedTurnOutputPersisted = false;
let pairedTurnStateFinalized = false;
let leaseHeartbeatTimer: ReturnType<typeof setInterval> | null = null; let leaseHeartbeatTimer: ReturnType<typeof setInterval> | 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 = () => { const clearLeaseHeartbeat = () => {
if (!leaseHeartbeatTimer) { if (!leaseHeartbeatTimer) {
return; return;
@@ -188,6 +215,10 @@ export function createPairedExecutionLifecycle(args: {
pairedExecutionCompleted = true; pairedExecutionCompleted = true;
}, },
markDelegated() {
pairedExecutionDelegated = true;
},
markStatus(status) { markStatus(status) {
pairedExecutionStatus = status; pairedExecutionStatus = status;
}, },
@@ -203,7 +234,26 @@ export function createPairedExecutionLifecycle(args: {
async asyncFinalize() { async asyncFinalize() {
clearLeaseHeartbeat(); clearLeaseHeartbeat();
if (pairedExecutionContext && !pairedExecutionCompleted) { 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 = const effectiveStatus =
completedRole === 'owner' && completedRole === 'owner' &&
pairedExecutionStatus === 'succeeded' && pairedExecutionStatus === 'succeeded' &&
@@ -211,6 +261,7 @@ export function createPairedExecutionLifecycle(args: {
? 'failed' ? 'failed'
: pairedExecutionStatus; : pairedExecutionStatus;
if (pairedExecutionContext && !pairedExecutionCompleted) {
if (effectiveStatus === 'succeeded') { if (effectiveStatus === 'succeeded') {
try { try {
persistPairedTurnOutputIfNeeded(); persistPairedTurnOutputIfNeeded();
@@ -232,6 +283,8 @@ export function createPairedExecutionLifecycle(args: {
pairedExecutionCompleted = true; pairedExecutionCompleted = true;
} }
finalizePairedTurnState(effectiveStatus);
if (!pairedExecutionContext) { if (!pairedExecutionContext) {
return; return;
} }
@@ -260,7 +313,7 @@ export function createPairedExecutionLifecycle(args: {
const queueAction = resolvePairedFollowUpQueueAction({ const queueAction = resolvePairedFollowUpQueueAction({
completedRole, completedRole,
executionStatus: pairedExecutionStatus, executionStatus: effectiveStatus,
sawOutput: pairedSawOutput, sawOutput: pairedSawOutput,
taskStatus: finishedTask?.status ?? null, taskStatus: finishedTask?.status ?? null,
}); });
@@ -274,7 +327,7 @@ export function createPairedExecutionLifecycle(args: {
task: finishedTask, task: finishedTask,
source: 'executor-recovery', source: 'executor-recovery',
completedRole, completedRole,
executionStatus: pairedExecutionStatus, executionStatus: effectiveStatus,
sawOutput: pairedSawOutput, sawOutput: pairedSawOutput,
fallbackLastTurnOutputRole: pairedSawOutput ? completedRole : null, fallbackLastTurnOutputRole: pairedSawOutput ? completedRole : null,
enqueueMessageCheck, enqueueMessageCheck,
@@ -286,7 +339,7 @@ export function createPairedExecutionLifecycle(args: {
{ {
taskId: pairedExecutionContext.task.id, taskId: pairedExecutionContext.task.id,
role: completedRole, role: completedRole,
pairedExecutionStatus, pairedExecutionStatus: effectiveStatus,
taskStatus: finishedTask.status, taskStatus: finishedTask.status,
intentKind: followUpResult.intentKind, intentKind: followUpResult.intentKind,
scheduled: followUpResult.scheduled, scheduled: followUpResult.scheduled,

View File

@@ -60,7 +60,9 @@ vi.mock('./db.js', () => {
[args.chatJid, args.taskId, args.taskUpdatedAt, args.intentKind].join(':'); [args.chatJid, args.taskId, args.taskUpdatedAt, args.intentKind].join(':');
return { return {
completePairedTurn: vi.fn(),
createServiceHandoff: vi.fn(), createServiceHandoff: vi.fn(),
failPairedTurn: vi.fn(),
getAllTasks: vi.fn(() => []), getAllTasks: vi.fn(() => []),
getLastHumanMessageSender: vi.fn(() => null), getLastHumanMessageSender: vi.fn(() => null),
getLatestOpenPairedTaskForChat: vi.fn(() => undefined), getLatestOpenPairedTaskForChat: vi.fn(() => undefined),
@@ -68,7 +70,9 @@ vi.mock('./db.js', () => {
getPairedTaskById: vi.fn(() => undefined), getPairedTaskById: vi.fn(() => undefined),
getPairedTurnOutputs: vi.fn(() => []), getPairedTurnOutputs: vi.fn(() => []),
insertPairedTurnOutput: vi.fn(), insertPairedTurnOutput: vi.fn(),
markPairedTurnRunning: vi.fn(),
refreshPairedTaskExecutionLease: vi.fn(() => true), refreshPairedTaskExecutionLease: vi.fn(() => true),
releasePairedTaskExecutionLease: vi.fn(),
reservePairedTurnReservation: vi.fn((args) => { reservePairedTurnReservation: vi.fn((args) => {
const key = buildReservationKey(args); const key = buildReservationKey(args);
if (pairedTurnReservations.has(key)) { if (pairedTurnReservations.has(key)) {
@@ -680,10 +684,169 @@ describe('runAgentForGroup room memory', () => {
expect.any(Object), expect.any(Object),
expect.any(Function), expect.any(Function),
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 () => { it('allows silent reviewer outputs', async () => {
const group = { ...makeGroup(), folder: 'test-group', workDir: '/repo' }; const group = { ...makeGroup(), folder: 'test-group', workDir: '/repo' };
vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue({ 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 () => { it('drops a stale Claude session id before retrying a fresh session', async () => {
const deps = { const deps = {
...makeDeps(), ...makeDeps(),

View File

@@ -22,6 +22,7 @@ import {
createServiceHandoff, createServiceHandoff,
getAllTasks, getAllTasks,
getLatestOpenPairedTaskForChat, getLatestOpenPairedTaskForChat,
markPairedTurnRunning,
} from './db.js'; } from './db.js';
import { GroupQueue } from './group-queue.js'; import { GroupQueue } from './group-queue.js';
import { createScopedLogger } from './logger.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 { preparePairedExecutionContext } from './paired-execution-context.js';
import { resolveCodexFallbackHandoff } from './paired-turn-fallback.js'; import { resolveCodexFallbackHandoff } from './paired-turn-fallback.js';
import { createPairedExecutionLifecycle } from './message-agent-executor-paired.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 { resolveExecutionTarget } from './message-runtime-rules.js';
import { buildRoomRoleContext } from './room-role-context.js'; import { buildRoomRoleContext } from './room-role-context.js';
import { type AgentTriggerReason } from './agent-error-detection.js'; import { type AgentTriggerReason } from './agent-error-detection.js';
@@ -82,6 +87,7 @@ export async function runAgentForGroup(
hasHumanMessage?: boolean; hasHumanMessage?: boolean;
forcedRole?: PairedRoomRole; forcedRole?: PairedRoomRole;
forcedAgentType?: AgentType; forcedAgentType?: AgentType;
pairedTurnIdentity?: PairedTurnIdentity;
onOutput?: (output: AgentOutput) => Promise<void>; onOutput?: (output: AgentOutput) => Promise<void>;
}, },
): Promise<'success' | 'error'> { ): Promise<'success' | 'error'> {
@@ -189,6 +195,75 @@ export async function runAgentForGroup(
roomRoleContext, roomRoleContext,
hasHumanMessage: args.hasHumanMessage, 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 // Forced fallbacks run under a different agent runtime, so keep the
// fallback session on its default model/effort unless explicitly configured // fallback session on its default model/effort unless explicitly configured
// for that runtime elsewhere. // for that runtime elsewhere.
@@ -203,6 +278,16 @@ export async function runAgentForGroup(
pairedExecutionContext.envOverrides[effortKey] = roleConfig.effort; 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({ const log = createScopedLogger({
chatJid, chatJid,
@@ -213,6 +298,7 @@ export async function runAgentForGroup(
messageSeqEnd: endSeq ?? undefined, messageSeqEnd: endSeq ?? undefined,
role: activeRole, role: activeRole,
serviceId: effectiveServiceId, serviceId: effectiveServiceId,
turnId: runtimePairedTurnIdentity?.turnId,
}); });
log.info( log.info(
{ {
@@ -306,6 +392,7 @@ export async function runAgentForGroup(
const effectivePrompt = moaEnrichedPrompt; const effectivePrompt = moaEnrichedPrompt;
const pairedExecutionLifecycle = createPairedExecutionLifecycle({ const pairedExecutionLifecycle = createPairedExecutionLifecycle({
pairedExecutionContext, pairedExecutionContext,
pairedTurnIdentity: runtimePairedTurnIdentity,
completedRole: roomRoleContext?.role ?? 'owner', completedRole: roomRoleContext?.role ?? 'owner',
chatJid, chatJid,
runId, runId,
@@ -349,8 +436,14 @@ export async function runAgentForGroup(
createServiceHandoff({ createServiceHandoff({
chat_jid: chatJid, chat_jid: chatJid,
group_folder: group.folder, 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, ...handoffResolution.plan.handoff,
}); });
pairedExecutionLifecycle.markDelegated();
log.warn({ reason }, handoffResolution.plan.logMessage); log.warn({ reason }, handoffResolution.plan.logMessage);
return true; return true;
}; };

View File

@@ -1,7 +1,10 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
import { _initTestDatabase } from './db.js'; import { _initTestDatabase } from './db.js';
import { executeBotOnlyPairedFollowUpAction } from './message-runtime-flow.js'; import {
executeBotOnlyPairedFollowUpAction,
executePendingPairedTurn,
} from './message-runtime-flow.js';
import { import {
resetPairedFollowUpScheduleState, resetPairedFollowUpScheduleState,
schedulePairedFollowUpOnce, 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',
},
}),
);
});
});

View File

@@ -20,6 +20,8 @@ import {
resolveCursorKey, resolveCursorKey,
resolveNextTurnAction, resolveNextTurnAction,
} from './message-runtime-rules.js'; } 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 { type ScheduledPairedFollowUpIntentKind } from './paired-follow-up-scheduler.js';
import { hasReviewerLease } from './service-routing.js'; import { hasReviewerLease } from './service-routing.js';
import type { import type {
@@ -35,9 +37,11 @@ export type PendingPairedTurn = {
prompt: string; prompt: string;
channel: Channel | null; channel: Channel | null;
cursor: string | number | null; cursor: string | number | null;
taskId: string;
taskUpdatedAt: string;
intentKind: PairedTurnReservationIntentKind; intentKind: PairedTurnReservationIntentKind;
cursorKey?: string; cursorKey?: string;
role?: 'reviewer' | 'arbiter'; role: PairedRoomRole;
} | null; } | null;
export type BotOnlyPairedFollowUpAction = export type BotOnlyPairedFollowUpAction =
@@ -127,6 +131,8 @@ export function buildPendingPairedTurn(args: {
}), }),
channel: resolveChannel(taskStatus), channel: resolveChannel(taskStatus),
cursor, cursor,
taskId: task.id,
taskUpdatedAt: task.updated_at,
intentKind: 'reviewer-turn', intentKind: 'reviewer-turn',
cursorKey: resolveCursorKey(chatJid, taskStatus), cursorKey: resolveCursorKey(chatJid, taskStatus),
role: 'reviewer', role: 'reviewer',
@@ -145,6 +151,8 @@ export function buildPendingPairedTurn(args: {
}), }),
channel: resolveChannel(taskStatus), channel: resolveChannel(taskStatus),
cursor, cursor,
taskId: task.id,
taskUpdatedAt: task.updated_at,
intentKind: 'arbiter-turn', intentKind: 'arbiter-turn',
cursorKey: resolveCursorKey(chatJid, taskStatus), cursorKey: resolveCursorKey(chatJid, taskStatus),
role: 'arbiter', role: 'arbiter',
@@ -156,7 +164,10 @@ export function buildPendingPairedTurn(args: {
prompt: buildFinalizePendingPrompt({ turnOutputs }), prompt: buildFinalizePendingPrompt({ turnOutputs }),
channel: resolveChannel(taskStatus), channel: resolveChannel(taskStatus),
cursor, cursor,
taskId: task.id,
taskUpdatedAt: task.updated_at,
intentKind: 'finalize-owner-turn', intentKind: 'finalize-owner-turn',
role: 'owner',
}; };
} }
@@ -171,7 +182,10 @@ export function buildPendingPairedTurn(args: {
}), }),
channel: resolveChannel(taskStatus), channel: resolveChannel(taskStatus),
cursor, cursor,
taskId: task.id,
taskUpdatedAt: task.updated_at,
intentKind: 'owner-follow-up', intentKind: 'owner-follow-up',
role: 'owner',
}; };
} }
@@ -186,16 +200,7 @@ export async function executePendingPairedTurn(args: {
log: typeof logger; log: typeof logger;
saveState: () => void; saveState: () => void;
lastAgentTimestamps: Record<string, string>; lastAgentTimestamps: Record<string, string>;
executeTurn: (args: { executeTurn: ExecuteTurnFn;
group: RegisteredGroup;
prompt: string;
chatJid: string;
runId: string;
channel: Channel;
startSeq: number | null;
endSeq: number | null;
deliveryRole?: PairedRoomRole;
}) => Promise<{ deliverySucceeded: boolean }>;
getFixedRoleChannelName: (role: 'reviewer' | 'arbiter') => string; getFixedRoleChannelName: (role: 'reviewer' | 'arbiter') => string;
}): Promise<boolean> { }): Promise<boolean> {
const { const {
@@ -211,7 +216,17 @@ export async function executePendingPairedTurn(args: {
} = args; } = args;
if (!pendingTurn.channel) { 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( log.error(
{ {
role: missingRole, role: missingRole,
@@ -239,6 +254,13 @@ export async function executePendingPairedTurn(args: {
runId, runId,
channel: pendingTurn.channel, channel: pendingTurn.channel,
deliveryRole: pendingTurn.role, deliveryRole: pendingTurn.role,
forcedRole: pendingTurn.role,
pairedTurnIdentity: buildPairedTurnIdentity({
taskId: pendingTurn.taskId,
taskUpdatedAt: pendingTurn.taskUpdatedAt,
intentKind: pendingTurn.intentKind,
role: pendingTurn.role,
}),
startSeq: null, startSeq: null,
endSeq: null, endSeq: null,
}); });
@@ -321,6 +343,8 @@ export async function executeBotOnlyPairedFollowUpAction(args: {
chatJid: string; chatJid: string;
runId: string; runId: string;
channel: Channel; channel: Channel;
forcedRole?: PairedRoomRole;
pairedTurnIdentity?: ReturnType<typeof buildPairedTurnIdentity>;
startSeq: number | null; startSeq: number | null;
endSeq: number | null; endSeq: number | null;
}) => Promise<{ deliverySucceeded: boolean }>; }) => Promise<{ deliverySucceeded: boolean }>;
@@ -395,6 +419,13 @@ export async function executeBotOnlyPairedFollowUpAction(args: {
chatJid, chatJid,
runId, runId,
channel, channel,
forcedRole: 'owner',
pairedTurnIdentity: buildPairedTurnIdentity({
taskId: action.task.id,
taskUpdatedAt: action.task.updated_at,
intentKind: 'finalize-owner-turn',
role: 'owner',
}),
startSeq: null, startSeq: null,
endSeq: null, endSeq: null,
}); });

View File

@@ -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');
});
});

View File

@@ -1,21 +1,25 @@
import { SERVICE_SESSION_SCOPE } from './config.js';
import { getErrorMessage } from './utils.js'; import { getErrorMessage } from './utils.js';
import { import {
claimServiceHandoff, claimServiceHandoff,
completeServiceHandoffAndAdvanceTargetCursor, completeServiceHandoffAndAdvanceTargetCursor,
failServiceHandoff, failServiceHandoff,
getAllPendingServiceHandoffs, getPendingServiceHandoffs,
type ServiceHandoff, type ServiceHandoff,
} from './db.js'; } from './db.js';
import { findChannel, findChannelByName } from './router.js'; import { findChannel, findChannelByName } from './router.js';
import { logger } from './logger.js'; import { logger } from './logger.js';
import { schedulePairedFollowUpWithMessageCheck } from './message-runtime-follow-up.js';
import { import {
getFixedRoleChannelName, getFixedRoleChannelName,
getMissingRoleChannelMessage, getMissingRoleChannelMessage,
resolveHandoffCursorKey, resolveHandoffCursorKey,
resolveHandoffRoleOverride, resolveHandoffRoleOverride,
} from './message-runtime-shared.js'; } 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 { 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: { export function enqueuePendingHandoffs(args: {
enqueueTask: ( enqueueTask: (
@@ -25,7 +29,7 @@ export function enqueuePendingHandoffs(args: {
) => void; ) => void;
processClaimedHandoff: (handoff: ServiceHandoff) => Promise<void>; processClaimedHandoff: (handoff: ServiceHandoff) => Promise<void>;
}): void { }): void {
for (const handoff of getAllPendingServiceHandoffs()) { for (const handoff of getPendingServiceHandoffs(SERVICE_SESSION_SCOPE)) {
if (!claimServiceHandoff(handoff.id)) { if (!claimServiceHandoff(handoff.id)) {
continue; continue;
} }
@@ -36,6 +40,124 @@ export function enqueuePendingHandoffs(args: {
} }
} }
function requeueFailedClaimedPairedTurn(args: {
handoff: ServiceHandoff;
error: string;
getPairedTaskById?:
| ((
id: string,
) =>
| Pick<PairedTask, 'id' | 'status' | 'round_trip_count' | 'updated_at'>
| 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<PairedTask, 'id' | 'status' | 'round_trip_count' | 'updated_at'>
| undefined)
| undefined;
enqueueMessageCheck?: ((chatJid: string) => void) | undefined;
}): void {
failServiceHandoff(args.handoff.id, args.error);
requeueFailedClaimedPairedTurn(args);
}
export async function processClaimedHandoff(args: { export async function processClaimedHandoff(args: {
handoff: ServiceHandoff; handoff: ServiceHandoff;
getRegisteredGroups: () => Record<string, RegisteredGroup>; getRegisteredGroups: () => Record<string, RegisteredGroup>;
@@ -43,21 +165,80 @@ export async function processClaimedHandoff(args: {
executeTurn: ExecuteTurnFn; executeTurn: ExecuteTurnFn;
lastAgentTimestamps: Record<string, string>; lastAgentTimestamps: Record<string, string>;
saveState: () => void; saveState: () => void;
getPairedTaskById?:
| ((
id: string,
) =>
| Pick<PairedTask, 'id' | 'status' | 'round_trip_count' | 'updated_at'>
| undefined)
| undefined;
enqueueMessageCheck?: ((chatJid: string) => void) | undefined;
}): Promise<void> { }): Promise<void> {
const { handoff } = args; const { handoff } = args;
const group = args.getRegisteredGroups()[handoff.chat_jid]; const group = args.getRegisteredGroups()[handoff.chat_jid];
if (!group) { 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; return;
} }
const channel = findChannel(args.channels, handoff.chat_jid); const channel = findChannel(args.channels, handoff.chat_jid);
if (!channel) { 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; return;
} }
const handoffRole = resolveHandoffRoleOverride(handoff); 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; let handoffChannel = channel;
if (handoffRole === 'reviewer') { if (handoffRole === 'reviewer') {
const reviewerChannel = findChannelByName( const reviewerChannel = findChannelByName(
@@ -65,7 +246,12 @@ export async function processClaimedHandoff(args: {
getFixedRoleChannelName('reviewer'), getFixedRoleChannelName('reviewer'),
); );
if (!reviewerChannel) { if (!reviewerChannel) {
failServiceHandoff(handoff.id, getMissingRoleChannelMessage('reviewer')); failClaimedHandoff({
handoff,
error: getMissingRoleChannelMessage('reviewer'),
getPairedTaskById: args.getPairedTaskById,
enqueueMessageCheck: args.enqueueMessageCheck,
});
return; return;
} }
handoffChannel = reviewerChannel; handoffChannel = reviewerChannel;
@@ -75,19 +261,37 @@ export async function processClaimedHandoff(args: {
getFixedRoleChannelName('arbiter'), getFixedRoleChannelName('arbiter'),
); );
if (!arbiterChannel) { if (!arbiterChannel) {
failServiceHandoff(handoff.id, getMissingRoleChannelMessage('arbiter')); failClaimedHandoff({
handoff,
error: getMissingRoleChannelMessage('arbiter'),
getPairedTaskById: args.getPairedTaskById,
enqueueMessageCheck: args.enqueueMessageCheck,
});
return; return;
} }
handoffChannel = arbiterChannel; handoffChannel = arbiterChannel;
} }
const runId = `handoff-${handoff.id}`; 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 { try {
logger.info( logger.info(
{ {
chatJid: handoff.chat_jid, chatJid: handoff.chat_jid,
handoffId: handoff.id, handoffId: handoff.id,
runId, runId,
turnId: pairedTurnIdentity?.turnId ?? handoff.turn_id ?? null,
handoffRole, handoffRole,
targetRole: handoff.target_role ?? null, targetRole: handoff.target_role ?? null,
targetServiceId: handoff.target_service_id, targetServiceId: handoff.target_service_id,
@@ -108,10 +312,16 @@ export async function processClaimedHandoff(args: {
endSeq: handoff.end_seq, endSeq: handoff.end_seq,
forcedRole: handoffRole, forcedRole: handoffRole,
forcedAgentType: handoff.target_agent_type, forcedAgentType: handoff.target_agent_type,
pairedTurnIdentity,
}); });
if (!result.deliverySucceeded) { if (!result.deliverySucceeded) {
failServiceHandoff(handoff.id, 'Handoff delivery failed'); failClaimedHandoff({
handoff,
error: 'Handoff delivery failed',
getPairedTaskById: args.getPairedTaskById,
enqueueMessageCheck: args.enqueueMessageCheck,
});
return; return;
} }
@@ -143,7 +353,12 @@ export async function processClaimedHandoff(args: {
); );
} catch (err) { } catch (err) {
const errorMessage = getErrorMessage(err); const errorMessage = getErrorMessage(err);
failServiceHandoff(handoff.id, errorMessage); failClaimedHandoff({
handoff,
error: errorMessage,
getPairedTaskById: args.getPairedTaskById,
enqueueMessageCheck: args.enqueueMessageCheck,
});
logger.error( logger.error(
{ chatJid: handoff.chat_jid, handoffId: handoff.id, err }, { chatJid: handoff.chat_jid, handoffId: handoff.id, err },
'Claimed service handoff failed', 'Claimed service handoff failed',

View File

@@ -12,6 +12,7 @@ import {
runPendingPairedTurnIfNeeded, runPendingPairedTurnIfNeeded,
runQueuedGroupTurn, runQueuedGroupTurn,
} from './message-runtime-queue.js'; } from './message-runtime-queue.js';
import { resetPairedFollowUpScheduleState } from './paired-follow-up-scheduler.js';
import type { Channel, PairedTask, RegisteredGroup } from './types.js'; import type { Channel, PairedTask, RegisteredGroup } from './types.js';
function makeGroup(): RegisteredGroup { function makeGroup(): RegisteredGroup {
@@ -64,6 +65,7 @@ function makeChannel(): Channel {
describe('message-runtime-queue', () => { describe('message-runtime-queue', () => {
beforeEach(() => { beforeEach(() => {
_initTestDatabase(); _initTestDatabase();
resetPairedFollowUpScheduleState();
}); });
it('skips a pending paired turn when another run already claimed the same task revision', async () => { 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(outcome).toBe(true);
expect(executeTurn).not.toHaveBeenCalled(); 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',
},
}),
);
});
}); });

View File

@@ -9,6 +9,7 @@ import {
executePendingPairedTurn, executePendingPairedTurn,
isBotOnlyPairedRoomTurn, isBotOnlyPairedRoomTurn,
} from './message-runtime-flow.js'; } from './message-runtime-flow.js';
import { buildPairedTurnIdentity } from './paired-turn-identity.js';
import { import {
advanceLastAgentCursor, advanceLastAgentCursor,
resolveActiveRole, resolveActiveRole,
@@ -202,8 +203,23 @@ export async function runQueuedGroupTurn(args: {
const turnChannel = const turnChannel =
turnRole === 'owner' ? args.ownerChannel : roleToChannel[turnRole]; turnRole === 'owner' ? args.ownerChannel : roleToChannel[turnRole];
const cursorKey = resolveCursorKeyForRole(chatJid, turnRole); const cursorKey = resolveCursorKeyForRole(chatJid, turnRole);
const forcedRole = const forcedRole = task ? turnRole : undefined;
task && turnRole !== resolveActiveRole(taskStatus) ? 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; let prompt: string;
if (turnRole === 'arbiter' && task) { if (turnRole === 'arbiter' && task) {
@@ -257,16 +273,11 @@ export async function runQueuedGroupTurn(args: {
} }
if (task) { if (task) {
const intentKind = resolveQueuedTurnReservationIntent({
task,
turnRole,
hasHumanMessage: hasHumanMsg,
});
const claimed = claimPairedTurnExecution({ const claimed = claimPairedTurnExecution({
chatJid, chatJid,
runId, runId,
task, task,
intentKind, intentKind: queuedIntentKind!,
}); });
if (!claimed) { if (!claimed) {
log.info( log.info(
@@ -274,7 +285,7 @@ export async function runQueuedGroupTurn(args: {
taskId: task.id, taskId: task.id,
taskStatus, taskStatus,
taskUpdatedAt: task.updated_at, taskUpdatedAt: task.updated_at,
intentKind, intentKind: queuedIntentKind,
turnRole, turnRole,
}, },
'Skipped queued paired turn because the task revision was already claimed elsewhere', 'Skipped queued paired turn because the task revision was already claimed elsewhere',
@@ -304,6 +315,7 @@ export async function runQueuedGroupTurn(args: {
endSeq, endSeq,
hasHumanMessage: hasHumanMsg, hasHumanMessage: hasHumanMsg,
forcedRole, forcedRole,
pairedTurnIdentity,
}); });
if (!deliverySucceeded) { if (!deliverySucceeded) {

View File

@@ -4,6 +4,7 @@ import type {
PairedRoomRole, PairedRoomRole,
RegisteredGroup, RegisteredGroup,
} from './types.js'; } from './types.js';
import type { PairedTurnIdentity } from './paired-turn-identity.js';
export type ExecuteTurnFn = (args: { export type ExecuteTurnFn = (args: {
group: RegisteredGroup; group: RegisteredGroup;
@@ -17,6 +18,7 @@ export type ExecuteTurnFn = (args: {
hasHumanMessage?: boolean; hasHumanMessage?: boolean;
forcedRole?: PairedRoomRole; forcedRole?: PairedRoomRole;
forcedAgentType?: AgentType; forcedAgentType?: AgentType;
pairedTurnIdentity?: PairedTurnIdentity;
}) => Promise<{ }) => Promise<{
outputStatus: 'success' | 'error'; outputStatus: 'success' | 'error';
deliverySucceeded: boolean; deliverySucceeded: boolean;

View File

@@ -79,7 +79,9 @@ vi.mock('./db.js', () => {
claimServiceHandoff: vi.fn(() => true), claimServiceHandoff: vi.fn(() => true),
completeServiceHandoff: vi.fn(), completeServiceHandoff: vi.fn(),
completeServiceHandoffAndAdvanceTargetCursor: vi.fn(), completeServiceHandoffAndAdvanceTargetCursor: vi.fn(),
completePairedTurn: vi.fn(),
failServiceHandoff: vi.fn(), failServiceHandoff: vi.fn(),
failPairedTurn: vi.fn(),
getAllChats: vi.fn(() => []), getAllChats: vi.fn(() => []),
getAllTasks: vi.fn(() => []), getAllTasks: vi.fn(() => []),
getAllPendingServiceHandoffs: vi.fn(() => []), getAllPendingServiceHandoffs: vi.fn(() => []),
@@ -135,6 +137,7 @@ vi.mock('./db.js', () => {
getOpenWorkItem(chatJid), getOpenWorkItem(chatJid),
), ),
getLatestOpenPairedTaskForChat: vi.fn(() => undefined), getLatestOpenPairedTaskForChat: vi.fn(() => undefined),
getPairedTaskById: vi.fn(() => undefined),
getPairedTurnOutputs: vi.fn(() => []), getPairedTurnOutputs: vi.fn(() => []),
getRecentChatMessages: vi.fn(() => []), getRecentChatMessages: vi.fn(() => []),
createProducedWorkItem: vi.fn((input) => ({ createProducedWorkItem: vi.fn((input) => ({
@@ -157,6 +160,7 @@ vi.mock('./db.js', () => {
})), })),
markWorkItemDelivered: vi.fn(), markWorkItemDelivered: vi.fn(),
markWorkItemDeliveryRetry: vi.fn(), markWorkItemDeliveryRetry: vi.fn(),
markPairedTurnRunning: vi.fn(),
getLastBotFinalMessage: vi.fn(() => []), getLastBotFinalMessage: vi.fn(() => []),
reservePairedTurnReservation: vi.fn((args) => { reservePairedTurnReservation: vi.fn((args) => {
const key = buildReservationKey(args); const key = buildReservationKey(args);
@@ -193,8 +197,13 @@ vi.mock('./service-routing.js', () => ({
hasReviewerLease: vi.fn(() => false), hasReviewerLease: vi.fn(() => false),
getEffectiveChannelLease: vi.fn((chatJid: string) => ({ getEffectiveChannelLease: vi.fn((chatJid: string) => ({
chat_jid: chatJid, chat_jid: chatJid,
owner_agent_type: 'claude-code',
reviewer_agent_type: 'claude-code',
arbiter_agent_type: 'claude-code',
owner_service_id: 'claude', owner_service_id: 'claude',
reviewer_service_id: 'codex-main', reviewer_service_id: 'codex-main',
arbiter_service_id: 'claude-arbiter',
owner_failover_active: false,
activated_at: null, activated_at: null,
reason: null, reason: null,
explicit: false, explicit: false,
@@ -202,9 +211,13 @@ vi.mock('./service-routing.js', () => ({
resolveLeaseServiceId: vi.fn( resolveLeaseServiceId: vi.fn(
( (
lease: { lease: {
owner_agent_type?: string;
reviewer_agent_type?: string | null;
arbiter_agent_type?: string | null;
owner_service_id: string; owner_service_id: string;
reviewer_service_id: string | null; reviewer_service_id: string | null;
arbiter_service_id?: string | null; arbiter_service_id?: string | null;
owner_failover_active?: boolean;
}, },
role: 'owner' | 'reviewer' | 'arbiter', 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.hasReviewerLease).mockReturnValue(true);
vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue({ vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue({
chat_jid: chatJid, chat_jid: chatJid,
owner_agent_type: 'claude-code',
reviewer_agent_type: 'claude-code',
arbiter_agent_type: null,
owner_service_id: 'claude', owner_service_id: 'claude',
reviewer_service_id: 'claude', reviewer_service_id: 'claude',
arbiter_service_id: null, arbiter_service_id: null,
owner_failover_active: false,
activated_at: null, activated_at: null,
reason: null, reason: null,
explicit: false, 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.hasReviewerLease).mockReturnValue(true);
vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue({ vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue({
chat_jid: chatJid, chat_jid: chatJid,
owner_agent_type: 'claude-code',
reviewer_agent_type: 'claude-code',
arbiter_agent_type: 'claude-code',
owner_service_id: 'claude', owner_service_id: 'claude',
reviewer_service_id: 'claude', reviewer_service_id: 'claude',
arbiter_service_id: null, arbiter_service_id: 'claude-arbiter',
owner_failover_active: false,
activated_at: null, activated_at: null,
reason: null, reason: null,
explicit: false, explicit: false,

View File

@@ -6,6 +6,7 @@ import {
getMessagesSinceSeq, getMessagesSinceSeq,
getLastBotFinalMessage, getLastBotFinalMessage,
getLatestOpenPairedTaskForChat, getLatestOpenPairedTaskForChat,
getPairedTaskById,
} from './db.js'; } from './db.js';
import { import {
isSessionCommandSenderAllowed, isSessionCommandSenderAllowed,
@@ -51,6 +52,7 @@ import {
} from './message-runtime-follow-up.js'; } from './message-runtime-follow-up.js';
import { runAgentForGroup } from './message-agent-executor.js'; import { runAgentForGroup } from './message-agent-executor.js';
import { MessageTurnController } from './message-turn-controller.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 { type ScheduledPairedFollowUpIntentKind } from './paired-follow-up-scheduler.js';
import { transitionPairedTaskStatus } from './paired-execution-context-shared.js'; import { transitionPairedTaskStatus } from './paired-execution-context-shared.js';
import { import {
@@ -208,6 +210,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
hasHumanMessage?: boolean; hasHumanMessage?: boolean;
forcedRole?: PairedRoomRole; forcedRole?: PairedRoomRole;
forcedAgentType?: AgentType; forcedAgentType?: AgentType;
pairedTurnIdentity?: PairedTurnIdentity;
}, },
): Promise<'success' | 'error'> => ): Promise<'success' | 'error'> =>
runAgentForGroup( runAgentForGroup(
@@ -229,6 +232,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
hasHumanMessage: options?.hasHumanMessage, hasHumanMessage: options?.hasHumanMessage,
forcedRole: options?.forcedRole, forcedRole: options?.forcedRole,
forcedAgentType: options?.forcedAgentType, forcedAgentType: options?.forcedAgentType,
pairedTurnIdentity: options?.pairedTurnIdentity,
onOutput, onOutput,
}, },
); );
@@ -245,6 +249,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
hasHumanMessage?: boolean; hasHumanMessage?: boolean;
forcedRole?: PairedRoomRole; forcedRole?: PairedRoomRole;
forcedAgentType?: AgentType; forcedAgentType?: AgentType;
pairedTurnIdentity?: PairedTurnIdentity;
}): Promise<{ }): Promise<{
outputStatus: 'success' | 'error'; outputStatus: 'success' | 'error';
deliverySucceeded: boolean; deliverySucceeded: boolean;
@@ -346,6 +351,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
hasHumanMessage: args.hasHumanMessage, hasHumanMessage: args.hasHumanMessage,
forcedRole: args.forcedRole, forcedRole: args.forcedRole,
forcedAgentType: args.forcedAgentType, forcedAgentType: args.forcedAgentType,
pairedTurnIdentity: args.pairedTurnIdentity,
}, },
); );
@@ -421,6 +427,9 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
executeTurn, executeTurn,
lastAgentTimestamps: deps.getLastAgentTimestamps(), lastAgentTimestamps: deps.getLastAgentTimestamps(),
saveState: deps.saveState, saveState: deps.saveState,
getPairedTaskById,
enqueueMessageCheck: (chatJid) =>
deps.queue.enqueueMessageCheck(chatJid),
}); });
}, },
}); });

View File

@@ -16,7 +16,12 @@ import {
_initTestDatabase, _initTestDatabase,
_initTestDatabaseFromFile, _initTestDatabaseFromFile,
createPairedTask, createPairedTask,
createServiceHandoff,
failServiceHandoff,
getPairedTaskById, getPairedTaskById,
getPairedTurnAttempts,
getPairedTurnById,
releasePairedTaskExecutionLease,
} from './db.js'; } from './db.js';
import { import {
buildPairedFollowUpKey, buildPairedFollowUpKey,
@@ -242,6 +247,140 @@ describe('paired follow-up scheduler', () => {
expect(second).toBe(false); expect(second).toBe(false);
expect(enqueue).toHaveBeenCalledTimes(1); 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', () => { it('keeps different round trips schedulable', () => {
const enqueue = vi.fn(); const enqueue = vi.fn();
@@ -374,6 +513,137 @@ describe('paired follow-up scheduler', () => {
expect(enqueue).toHaveBeenCalledTimes(1); 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', () => { it('blocks a fresh-refetched task revision from reclaiming the same turn while the execution lease is active', () => {
const task = { const task = {
id: 'task-1', 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', () => { it('preserves legacy execution leases that belong to another service during startup cleanup', () => {
const tempDir = fs.mkdtempSync( const tempDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'ejclaw-paired-lease-preserve-'), path.join(os.tmpdir(), 'ejclaw-paired-lease-preserve-'),

103
src/paired-turn-identity.ts Normal file
View File

@@ -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,
});
}