Finalize turn-role invariants and legacy migration cutover
This commit is contained in:
@@ -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:
|
||||||
|
|
||||||
@@ -90,17 +91,17 @@ 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 |
|
||||||
| `src/channels/discord.ts` | Discord channel (8s typing refresh, Whisper transcription) |
|
| `src/channels/discord.ts` | Discord channel (8s typing refresh, Whisper transcription) |
|
||||||
| `src/ipc.ts` | IPC watcher and task processing |
|
| `src/ipc.ts` | IPC watcher and task processing |
|
||||||
| `src/router.ts` | Message formatting and outbound routing |
|
| `src/router.ts` | Message formatting and outbound routing |
|
||||||
| `src/config.ts` | Trigger pattern, paths, intervals |
|
| `src/config.ts` | Trigger pattern, paths, intervals |
|
||||||
| `src/task-scheduler.ts` | Runs scheduled tasks |
|
| `src/task-scheduler.ts` | Runs scheduled tasks |
|
||||||
| `src/db.ts` | SQLite operations |
|
| `src/db.ts` | SQLite operations |
|
||||||
| `runners/agent-runner/` | Claude Code runner (Agent SDK) |
|
| `runners/agent-runner/` | Claude Code runner (Agent SDK) |
|
||||||
| `runners/codex-runner/` | Codex runner (SDK, `codex exec` wrapper) |
|
| `runners/codex-runner/` | Codex runner (SDK, `codex exec` wrapper) |
|
||||||
| `groups/{name}/CLAUDE.md` | Per-group memory (isolated) |
|
| `groups/{name}/CLAUDE.md` | Per-group memory (isolated) |
|
||||||
|
|||||||
744
docs/legacy-compat-removal-spec.md
Normal file
744
docs/legacy-compat-removal-spec.md
Normal 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 같은 명시적 예외 파일에만 존재
|
||||||
309
docs/turn-role-invariant-spec-1.md
Normal file
309
docs/turn-role-invariant-spec-1.md
Normal 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.
|
||||||
@@ -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 });
|
||||||
jid TEXT PRIMARY KEY,
|
}
|
||||||
name TEXT NOT NULL,
|
|
||||||
folder TEXT NOT NULL UNIQUE,
|
|
||||||
trigger_pattern TEXT NOT NULL,
|
|
||||||
added_at TEXT NOT NULL,
|
|
||||||
agent_config TEXT,
|
|
||||||
requires_trigger INTEGER DEFAULT 1
|
|
||||||
)`);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns 0 for empty table', () => {
|
it('returns false when the database does not exist', () => {
|
||||||
const row = db
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-env-'));
|
||||||
.prepare('SELECT COUNT(*) as count FROM registered_groups')
|
tempDirs.push(tempDir);
|
||||||
.get() as { count: number };
|
|
||||||
expect(row.count).toBe(0);
|
expect(
|
||||||
|
detectAssignedRooms({ dbPath: path.join(tempDir, 'messages.db') }),
|
||||||
|
).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns correct count after inserts', () => {
|
it('returns false for an empty room_settings table', () => {
|
||||||
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-env-'));
|
||||||
|
tempDirs.push(tempDir);
|
||||||
|
const dbPath = path.join(tempDir, 'messages.db');
|
||||||
|
const db = new Database(dbPath);
|
||||||
|
db.exec(`CREATE TABLE room_settings (chat_jid TEXT PRIMARY KEY)`);
|
||||||
|
db.close();
|
||||||
|
|
||||||
|
expect(detectAssignedRooms({ dbPath })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns true when canonical room assignments exist', () => {
|
||||||
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-env-'));
|
||||||
|
tempDirs.push(tempDir);
|
||||||
|
const dbPath = path.join(tempDir, 'messages.db');
|
||||||
|
const db = new Database(dbPath);
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE room_settings (
|
||||||
|
chat_jid TEXT PRIMARY KEY,
|
||||||
|
room_mode TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
)
|
||||||
|
`);
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`INSERT INTO registered_groups (jid, name, folder, trigger_pattern, added_at, requires_trigger)
|
`INSERT INTO room_settings (chat_jid, room_mode, updated_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?)`,
|
||||||
).run(
|
).run('dc:room-1', 'single', '2024-01-01T00:00:00.000Z');
|
||||||
'123@g.us',
|
db.close();
|
||||||
'Group 1',
|
|
||||||
'group-1',
|
expect(detectAssignedRooms({ dbPath })).toBe(true);
|
||||||
'@Andy',
|
});
|
||||||
'2024-01-01T00:00:00.000Z',
|
|
||||||
1,
|
it('returns false when only legacy registered_groups rows exist', () => {
|
||||||
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-env-'));
|
||||||
|
tempDirs.push(tempDir);
|
||||||
|
const dbPath = path.join(tempDir, 'messages.db');
|
||||||
|
const db = new Database(dbPath);
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE registered_groups (
|
||||||
|
jid TEXT PRIMARY KEY,
|
||||||
|
agent_type TEXT
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
db.exec(`
|
||||||
|
INSERT INTO registered_groups (jid, agent_type)
|
||||||
|
VALUES ('dc:legacy-room', 'claude-code')
|
||||||
|
`);
|
||||||
|
db.close();
|
||||||
|
|
||||||
|
expect(detectAssignedRooms({ dbPath })).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('environment step legacy-room handling', () => {
|
||||||
|
const originalCwd = process.cwd();
|
||||||
|
const tempDirs: string[] = [];
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.chdir(originalCwd);
|
||||||
|
for (const dir of tempDirs.splice(0)) {
|
||||||
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exits with failure when legacy-only room registrations need migration', async () => {
|
||||||
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-env-run-'));
|
||||||
|
tempDirs.push(tempDir);
|
||||||
|
process.chdir(tempDir);
|
||||||
|
|
||||||
|
fs.mkdirSync(path.join(tempDir, 'store'), { recursive: true });
|
||||||
|
const db = new Database(path.join(tempDir, 'store', 'messages.db'));
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE registered_groups (
|
||||||
|
jid TEXT PRIMARY KEY,
|
||||||
|
agent_type TEXT
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
db.exec(`
|
||||||
|
INSERT INTO registered_groups (jid, agent_type)
|
||||||
|
VALUES ('dc:legacy-room', 'claude-code')
|
||||||
|
`);
|
||||||
|
db.close();
|
||||||
|
|
||||||
|
const exitSpy = vi
|
||||||
|
.spyOn(process, 'exit')
|
||||||
|
.mockImplementation((code?: string | number | null | undefined) => {
|
||||||
|
throw new Error(`process.exit:${code}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(runEnvironment([])).rejects.toThrow('process.exit:1');
|
||||||
|
exitSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exits with failure when canonical rooms exist but pending legacy registrations remain', async () => {
|
||||||
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-env-run-'));
|
||||||
|
tempDirs.push(tempDir);
|
||||||
|
process.chdir(tempDir);
|
||||||
|
|
||||||
|
fs.mkdirSync(path.join(tempDir, 'store'), { recursive: true });
|
||||||
|
const db = new Database(path.join(tempDir, 'store', 'messages.db'));
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE room_settings (
|
||||||
|
chat_jid TEXT PRIMARY KEY,
|
||||||
|
room_mode TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE registered_groups (
|
||||||
|
jid TEXT PRIMARY KEY,
|
||||||
|
agent_type TEXT
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
db.exec(`
|
||||||
|
INSERT INTO room_settings (chat_jid, room_mode)
|
||||||
|
VALUES ('dc:canonical-room', 'single')
|
||||||
|
`);
|
||||||
|
db.exec(`
|
||||||
|
INSERT INTO registered_groups (jid, agent_type)
|
||||||
|
VALUES ('dc:legacy-room', 'claude-code')
|
||||||
|
`);
|
||||||
|
db.close();
|
||||||
|
|
||||||
|
const exitSpy = vi
|
||||||
|
.spyOn(process, 'exit')
|
||||||
|
.mockImplementation((code?: string | number | null | undefined) => {
|
||||||
|
throw new Error(`process.exit:${code}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(runEnvironment([])).rejects.toThrow('process.exit:1');
|
||||||
|
exitSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exits with failure when legacy json state files need explicit migration', async () => {
|
||||||
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-env-run-'));
|
||||||
|
tempDirs.push(tempDir);
|
||||||
|
process.chdir(tempDir);
|
||||||
|
|
||||||
|
fs.mkdirSync(path.join(tempDir, 'data'), { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(tempDir, 'data', 'router_state.json'),
|
||||||
|
JSON.stringify({ last_timestamp: '1234' }),
|
||||||
);
|
);
|
||||||
|
|
||||||
db.prepare(
|
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);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
152
setup/migrate-json-state.test.ts
Normal file
152
setup/migrate-json-state.test.ts
Normal 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
116
setup/migrate-json-state.ts
Normal 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',
|
||||||
|
});
|
||||||
|
}
|
||||||
726
setup/migrate-room-registrations.test.ts
Normal file
726
setup/migrate-room-registrations.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
371
setup/migrate-room-registrations.ts
Normal file
371
setup/migrate-room-registrations.ts
Normal 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',
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
'--folder',
|
||||||
|
'test-room',
|
||||||
|
'--assistant-name',
|
||||||
|
'Nova',
|
||||||
|
]),
|
||||||
|
).rejects.toThrow('process.exit:4');
|
||||||
|
|
||||||
|
expect(initDatabaseMock).not.toHaveBeenCalled();
|
||||||
|
expect(assignRoomMock).not.toHaveBeenCalled();
|
||||||
|
expect(emitStatusMock).toHaveBeenCalledWith('REGISTER_CHANNEL', {
|
||||||
|
STATUS: 'failed',
|
||||||
|
ERROR: 'assistant_name_option_removed',
|
||||||
|
NEXT_STEP:
|
||||||
|
'Use a dedicated assistant identity configuration command instead',
|
||||||
|
LOG: 'logs/setup.log',
|
||||||
|
});
|
||||||
|
|
||||||
|
exitSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never writes prompt or env files during registration', async () => {
|
||||||
|
const mkdirSpy = vi
|
||||||
|
.spyOn(fs, 'mkdirSync')
|
||||||
|
.mockImplementation(() => undefined);
|
||||||
|
const readSpy = vi.spyOn(fs, 'readFileSync');
|
||||||
|
const writeSpy = vi.spyOn(fs, 'writeFileSync');
|
||||||
|
|
||||||
|
await run([
|
||||||
|
'--jid',
|
||||||
|
'dc:main-room',
|
||||||
|
'--name',
|
||||||
|
'Main Room',
|
||||||
|
'--trigger',
|
||||||
'@Andy',
|
'@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 name FROM registered_groups WHERE jid = ?')
|
name: 'Main Room',
|
||||||
.get('456@g.us') as {
|
folder: 'main-room',
|
||||||
name: string;
|
trigger: '@Andy',
|
||||||
};
|
requiresTrigger: false,
|
||||||
|
isMain: true,
|
||||||
expect(row.name).toBe(name);
|
});
|
||||||
});
|
expect(mkdirSpy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(readSpy).not.toHaveBeenCalled();
|
||||||
it('prevents SQL injection in JID field', () => {
|
expect(writeSpy).not.toHaveBeenCalled();
|
||||||
const maliciousJid = "'; DROP TABLE registered_groups; --";
|
|
||||||
|
|
||||||
db.prepare(
|
|
||||||
`INSERT OR REPLACE INTO registered_groups
|
|
||||||
(jid, name, folder, trigger_pattern, added_at, agent_config, requires_trigger)
|
|
||||||
VALUES (?, ?, ?, ?, ?, NULL, ?)`,
|
|
||||||
).run(maliciousJid, 'Evil', 'evil', '@Andy', '2024-01-01T00:00:00.000Z', 1);
|
|
||||||
|
|
||||||
// Table should still exist and have the row
|
|
||||||
const count = db
|
|
||||||
.prepare('SELECT COUNT(*) as count FROM registered_groups')
|
|
||||||
.get() as {
|
|
||||||
count: number;
|
|
||||||
};
|
|
||||||
expect(count.count).toBe(1);
|
|
||||||
|
|
||||||
const row = db.prepare('SELECT jid FROM registered_groups').get() as {
|
|
||||||
jid: string;
|
|
||||||
};
|
|
||||||
expect(row.jid).toBe(maliciousJid);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles requiresTrigger=false', () => {
|
|
||||||
db.prepare(
|
|
||||||
`INSERT OR REPLACE INTO registered_groups
|
|
||||||
(jid, name, folder, trigger_pattern, added_at, agent_config, requires_trigger)
|
|
||||||
VALUES (?, ?, ?, ?, ?, NULL, ?)`,
|
|
||||||
).run(
|
|
||||||
'dc:789',
|
|
||||||
'Personal',
|
|
||||||
'main',
|
|
||||||
'@Andy',
|
|
||||||
'2024-01-01T00:00:00.000Z',
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
|
|
||||||
const row = db
|
|
||||||
.prepare('SELECT requires_trigger FROM registered_groups WHERE jid = ?')
|
|
||||||
.get('dc:789') as { requires_trigger: number };
|
|
||||||
|
|
||||||
expect(row.requires_trigger).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('stores is_main flag', () => {
|
|
||||||
db.prepare(
|
|
||||||
`INSERT OR REPLACE INTO registered_groups
|
|
||||||
(jid, name, folder, trigger_pattern, added_at, agent_config, requires_trigger, is_main)
|
|
||||||
VALUES (?, ?, ?, ?, ?, NULL, ?, ?)`,
|
|
||||||
).run(
|
|
||||||
'dc:789',
|
|
||||||
'Personal',
|
|
||||||
'discord_main',
|
|
||||||
'@Andy',
|
|
||||||
'2024-01-01T00:00:00.000Z',
|
|
||||||
0,
|
|
||||||
1,
|
|
||||||
);
|
|
||||||
|
|
||||||
const row = db
|
|
||||||
.prepare('SELECT is_main FROM registered_groups WHERE jid = ?')
|
|
||||||
.get('dc:789') as { is_main: number };
|
|
||||||
|
|
||||||
expect(row.is_main).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('defaults is_main to 0', () => {
|
|
||||||
db.prepare(
|
|
||||||
`INSERT OR REPLACE INTO registered_groups
|
|
||||||
(jid, name, folder, trigger_pattern, added_at, agent_config, requires_trigger)
|
|
||||||
VALUES (?, ?, ?, ?, ?, NULL, ?)`,
|
|
||||||
).run(
|
|
||||||
'123@g.us',
|
|
||||||
'Some Group',
|
|
||||||
'discord_some-group',
|
|
||||||
'@Andy',
|
|
||||||
'2024-01-01T00:00:00.000Z',
|
|
||||||
1,
|
|
||||||
);
|
|
||||||
|
|
||||||
const row = db
|
|
||||||
.prepare('SELECT is_main FROM registered_groups WHERE jid = ?')
|
|
||||||
.get('123@g.us') as { is_main: number };
|
|
||||||
|
|
||||||
expect(row.is_main).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('upserts on conflict', () => {
|
|
||||||
const stmt = db.prepare(
|
|
||||||
`INSERT OR REPLACE INTO registered_groups
|
|
||||||
(jid, name, folder, trigger_pattern, added_at, agent_config, requires_trigger)
|
|
||||||
VALUES (?, ?, ?, ?, ?, NULL, ?)`,
|
|
||||||
);
|
|
||||||
|
|
||||||
stmt.run(
|
|
||||||
'123@g.us',
|
|
||||||
'Original',
|
|
||||||
'main',
|
|
||||||
'@Andy',
|
|
||||||
'2024-01-01T00:00:00.000Z',
|
|
||||||
1,
|
|
||||||
);
|
|
||||||
stmt.run(
|
|
||||||
'123@g.us',
|
|
||||||
'Updated',
|
|
||||||
'main',
|
|
||||||
'@Bot',
|
|
||||||
'2024-02-01T00:00:00.000Z',
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
|
|
||||||
const rows = db.prepare('SELECT * FROM registered_groups').all();
|
|
||||||
expect(rows).toHaveLength(1);
|
|
||||||
|
|
||||||
const row = rows[0] as {
|
|
||||||
name: string;
|
|
||||||
trigger_pattern: string;
|
|
||||||
requires_trigger: number;
|
|
||||||
};
|
|
||||||
expect(row.name).toBe('Updated');
|
|
||||||
expect(row.trigger_pattern).toBe('@Bot');
|
|
||||||
expect(row.requires_trigger).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('file templating', () => {
|
|
||||||
it('replaces assistant name in CLAUDE.md content', () => {
|
|
||||||
let content = '# Andy\n\nYou are Andy, a personal assistant.';
|
|
||||||
|
|
||||||
content = content.replace(/^# Andy$/m, '# Nova');
|
|
||||||
content = content.replace(/You are Andy/g, 'You are Nova');
|
|
||||||
|
|
||||||
expect(content).toBe('# Nova\n\nYou are Nova, a personal assistant.');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles names with special regex characters', () => {
|
|
||||||
let content = '# Andy\n\nYou are Andy.';
|
|
||||||
|
|
||||||
const newName = 'C.L.A.U.D.E';
|
|
||||||
content = content.replace(/^# Andy$/m, `# ${newName}`);
|
|
||||||
content = content.replace(/You are Andy/g, `You are ${newName}`);
|
|
||||||
|
|
||||||
expect(content).toContain('# C.L.A.U.D.E');
|
|
||||||
expect(content).toContain('You are C.L.A.U.D.E.');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('updates .env ASSISTANT_NAME line', () => {
|
|
||||||
let envContent = 'SOME_KEY=value\nASSISTANT_NAME="Andy"\nOTHER=test';
|
|
||||||
|
|
||||||
envContent = envContent.replace(
|
|
||||||
/^ASSISTANT_NAME=.*$/m,
|
|
||||||
'ASSISTANT_NAME="Nova"',
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(envContent).toContain('ASSISTANT_NAME="Nova"');
|
|
||||||
expect(envContent).toContain('SOME_KEY=value');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('appends ASSISTANT_NAME to .env if not present', () => {
|
|
||||||
let envContent = 'SOME_KEY=value\n';
|
|
||||||
|
|
||||||
if (!envContent.includes('ASSISTANT_NAME=')) {
|
|
||||||
envContent += '\nASSISTANT_NAME="Nova"';
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(envContent).toContain('ASSISTANT_NAME="Nova"');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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',
|
||||||
});
|
});
|
||||||
|
|||||||
149
setup/room-registration-state.ts
Normal file
149
setup/room-registration-state.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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'],
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,39 +95,14 @@ export function loadRoleRoutingRequirementsSummary(
|
|||||||
.prepare(
|
.prepare(
|
||||||
`
|
`
|
||||||
SELECT COUNT(*) as count
|
SELECT COUNT(*) as count
|
||||||
FROM (
|
FROM room_settings
|
||||||
SELECT chat_jid AS jid
|
WHERE room_mode = 'tribunal'
|
||||||
FROM room_settings
|
|
||||||
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 {
|
} catch {
|
||||||
try {
|
tribunalRooms = 0;
|
||||||
const fallbackRow = db
|
|
||||||
.prepare(
|
|
||||||
`
|
|
||||||
SELECT COUNT(*) as count
|
|
||||||
FROM (
|
|
||||||
SELECT jid
|
|
||||||
FROM registered_groups
|
|
||||||
GROUP BY jid
|
|
||||||
HAVING COUNT(DISTINCT agent_type) > 1
|
|
||||||
)
|
|
||||||
`,
|
|
||||||
)
|
|
||||||
.get() as { count: number };
|
|
||||||
tribunalRooms = fallbackRow.count;
|
|
||||||
} catch {
|
|
||||||
tribunalRooms = 0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -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,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
});
|
});
|
||||||
|
|||||||
5078
src/db.test.ts
5078
src/db.test.ts
File diff suppressed because it is too large
Load Diff
211
src/db.ts
211
src/db.ts
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
374
src/db/canonical-role-metadata.ts
Normal file
374
src/db/canonical-role-metadata.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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(
|
||||||
`
|
`
|
||||||
@@ -501,26 +497,30 @@ export function claimPairedTurnReservationInDatabase(
|
|||||||
const insertedLease = database
|
const insertedLease = database
|
||||||
.prepare(
|
.prepare(
|
||||||
`
|
`
|
||||||
INSERT INTO paired_task_execution_leases (
|
INSERT INTO paired_task_execution_leases (
|
||||||
task_id,
|
task_id,
|
||||||
chat_jid,
|
chat_jid,
|
||||||
role,
|
role,
|
||||||
intent_kind,
|
turn_id,
|
||||||
claimed_run_id,
|
turn_attempt_id,
|
||||||
claimed_service_id,
|
turn_attempt_no,
|
||||||
|
intent_kind,
|
||||||
|
claimed_run_id,
|
||||||
|
claimed_service_id,
|
||||||
task_status,
|
task_status,
|
||||||
task_updated_at,
|
task_updated_at,
|
||||||
claimed_at,
|
claimed_at,
|
||||||
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 {
|
||||||
|
|||||||
451
src/db/paired-turn-attempts.ts
Normal file
451
src/db/paired-turn-attempts.ts
Normal 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
515
src/db/paired-turns.ts
Normal 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();
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
2504
src/db/schema.ts
2504
src/db/schema.ts
File diff suppressed because it is too large
Load Diff
@@ -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,15 +402,36 @@ export function failServiceHandoffInDatabase(
|
|||||||
id: number,
|
id: number,
|
||||||
error: string,
|
error: string,
|
||||||
): void {
|
): void {
|
||||||
database
|
database.transaction(() => {
|
||||||
.prepare(
|
const row = database
|
||||||
`UPDATE service_handoffs
|
.prepare('SELECT * FROM service_handoffs WHERE id = ?')
|
||||||
SET status = 'failed',
|
.get(id) as StoredServiceHandoffRow | undefined;
|
||||||
completed_at = datetime('now'),
|
let turnIdentity: PairedTurnIdentity | null = null;
|
||||||
last_error = ?
|
if (row) {
|
||||||
WHERE id = ?`,
|
try {
|
||||||
)
|
turnIdentity = hydrateStoredTurnIdentity(row);
|
||||||
.run(error, id);
|
} catch {
|
||||||
|
turnIdentity = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
database
|
||||||
|
.prepare(
|
||||||
|
`UPDATE service_handoffs
|
||||||
|
SET status = 'failed',
|
||||||
|
completed_at = datetime('now'),
|
||||||
|
last_error = ?
|
||||||
|
WHERE id = ?`,
|
||||||
|
)
|
||||||
|
.run(error, id);
|
||||||
|
|
||||||
|
if (turnIdentity) {
|
||||||
|
failPairedTurnInDatabase(database, {
|
||||||
|
turnIdentity,
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function completeServiceHandoffAndAdvanceTargetCursorInDatabase(
|
export function completeServiceHandoffAndAdvanceTargetCursorInDatabase(
|
||||||
|
|||||||
@@ -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>)
|
||||||
|
|||||||
@@ -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,14 +234,34 @@ export function createPairedExecutionLifecycle(args: {
|
|||||||
async asyncFinalize() {
|
async asyncFinalize() {
|
||||||
clearLeaseHeartbeat();
|
clearLeaseHeartbeat();
|
||||||
|
|
||||||
if (pairedExecutionContext && !pairedExecutionCompleted) {
|
if (pairedExecutionContext && pairedExecutionDelegated) {
|
||||||
const effectiveStatus =
|
try {
|
||||||
completedRole === 'owner' &&
|
releasePairedTaskExecutionLease({
|
||||||
pairedExecutionStatus === 'succeeded' &&
|
taskId: pairedExecutionContext.task.id,
|
||||||
!pairedSawOutput
|
runId,
|
||||||
? 'failed'
|
});
|
||||||
: pairedExecutionStatus;
|
} catch (err) {
|
||||||
|
log.warn(
|
||||||
|
{
|
||||||
|
pairedTaskId: pairedExecutionContext.task.id,
|
||||||
|
runId,
|
||||||
|
err,
|
||||||
|
},
|
||||||
|
'Failed to release paired execution lease for delegated fallback handoff',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
pairedExecutionCompleted = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const effectiveStatus =
|
||||||
|
completedRole === 'owner' &&
|
||||||
|
pairedExecutionStatus === 'succeeded' &&
|
||||||
|
!pairedSawOutput
|
||||||
|
? 'failed'
|
||||||
|
: pairedExecutionStatus;
|
||||||
|
|
||||||
|
if (pairedExecutionContext && !pairedExecutionCompleted) {
|
||||||
if (effectiveStatus === 'succeeded') {
|
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,
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
283
src/message-runtime-handoffs.test.ts
Normal file
283
src/message-runtime-handoffs.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
103
src/paired-turn-identity.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user