agent-ecosystem/docs/team-management/research-inbox.md
iliya 2eac440fe2 fix(team): disable teammate DM relay through lead, read user.json directly
Teammates are independent Claude Code processes that read their own inbox
files via fs.watch. Relaying DMs through the lead caused three bugs:
lead responding instead of the teammate, duplicate messages from relay
loops, and teammates not responding to user due to conflicting prompts.

- Disable relayMemberInboxMessages for teammate DMs (teams.ts, index.ts)
- Add SendMessage(to="user") filter in captureSendMessages as safety net
- Generate deterministic messageId for inbox entries lacking one (sha256)
- Wrap notification instructions in agent block, italic task subject
- Style system comments in task view with blue background, hide avatar
- Update CLAUDE.md, research docs, and code comments with architecture
2026-03-23 12:57:16 +02:00

228 lines
8.8 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Research: Inbox-файлы Claude Code
## Формат
**Путь**: `~/.claude/teams/{teamName}/inboxes/{memberName}.json`
**Структура**: JSON-массив объектов (весь файл = массив)
```json
[
{
"from": "contracts-cleaner",
"text": "Готово. Вот что было удалено из packages/core/...",
"timestamp": "2026-02-09T17:12:32.316Z",
"color": "blue",
"read": true,
"summary": "Cleanup complete"
},
{
"from": "team-lead",
"text": "{\"type\":\"shutdown_request\",\"from\":\"team-lead\",\"timestamp\":\"...\"}",
"timestamp": "2026-02-09T17:25:43.886Z",
"read": true
}
]
```
### Поля
| Поле | Обязательно | Тип | Описание |
|------|:-----------:|-----|----------|
| `from` | YES | string | Имя отправителя (зарегистрированный agent) |
| `text` | YES | string | Текст сообщения или JSON-строка |
| `timestamp` | YES | string | ISO 8601 |
| `read` | YES | boolean | Прочитано ли Claude Code |
| `summary` | NO | string | Краткое описание для UI |
| `color` | NO | string | Цвет агента (blue, green, red, yellow, purple, cyan, orange, pink) |
### text: plain vs structured
`text` может быть:
- **Plain text**: обычное сообщение
- **JSON-строка**: структурированное сообщение (парсится из text)
Типы структурированных:
```
idle_notification — агент ушёл в idle (idleReason: "available")
shutdown_request — запрос на завершение работы
shutdown_approved — подтверждение завершения
message — обычное DM (content, summary)
task_completed — задача завершена (taskId)
```
### read поведение
- Claude Code ставит `read: true` после обработки
- Сообщения НЕ УДАЛЯЮТСЯ — остаются навечно
- Нет автоматической чистки inbox
- 106 сообщений = ~256 KB
---
## Race Condition (КРИТИЧНЫЙ РИСК)
### Сценарий
```
T=0ms: App читает inbox [msg1, msg2]
T=1ms: Claude Code читает inbox [msg1, msg2]
T=5ms: App пишет [msg1, msg2, msg3_app]
T=6ms: Claude Code пишет [msg1, msg2, msg3_agent]
→ msg3_app ПОТЕРЯНО
```
### Почему происходит
Inbox — JSON-массив. Append = read whole array → add element → write whole file. Два процесса читают одну версию, каждый добавляет своё, последний перезаписывает первого.
### Вероятность
**НИЗКАЯ** — записи в inbox происходят нечасто:
- Юзер шлёт 1-2 сообщения в минуту
- Агенты шлют idle_notification раз в 5-30 секунд
- Коллизия = оба пишут в ОДИН файл в пределах ~10ms
### Митигация
1. **Atomic write** (предотвращает partial writes):
```typescript
const tmpPath = targetPath + '.tmp.' + process.pid;
fs.writeFileSync(tmpPath, JSON.stringify(messages, null, 2));
fs.renameSync(tmpPath, targetPath); // atomic на macOS/Linux
```
2. **Retry с проверкой** (обнаруживает потерю):
```typescript
// После записи — перечитать и проверить
const written = JSON.parse(fs.readFileSync(inboxPath, 'utf8'));
const found = written.some(m => m.messageId === ourMessageId);
if (!found) {
// Наше сообщение потеряно — retry
await appendToInbox(inboxPath, message);
}
```
3. **Уникальный messageId**: добавлять `messageId: uuid()` в наши сообщения
4. **Debounce**: не писать чаще раз в 500ms
### Что НЕ решает
Atomic write предотвращает corrupted JSON, но НЕ предотвращает overwrite race. Retry с проверкой — best effort, но не 100%.
---
## Доставка сообщений
### Между turns, НЕ real-time
```
Цикл тиммейта:
1. Читает inbox (видит новые сообщения с read: false)
2. Обрабатывает
3. Вызывает инструменты (Bash, Edit, Read...)
4. Turn заканчивается → шлёт idle_notification → IDLE
5. Ждёт...
6. Новый turn → читает inbox
```
**Задержка**: 1-30 секунд (зависит от длительности turn)
### Нельзя прервать mid-turn
Если агент уже вызвал Edit/Bash — инструмент будет выполнен. Сообщение "стоп, не трогай файл X" придёт ПОСЛЕ.
### Idle → Active
Сообщение idle-агенту пробуждает его при следующем цикле проверки inbox.
### Hard Interrupt (будущее)
Возможные подходы:
- `kill -SIGINT` процесса (жёсткое)
- Файловый flag `.interrupt-{member}`
- Ждать API от Anthropic
---
## from: "user" — подтверждено работает (2026-03-23)
Эмпирически подтверждено: `from: "user"` корректно доставляется тиммейтам. Тиммейт получает сообщение, определяет что оно от юзера, и отвечает в `inboxes/user.json`. Fallback на `from: "team-lead"` не нужен.
Ранее были опасения что Claude Code валидирует `from` по `config.json` members — это не так.
---
## Размер и масштабирование
| Метрика | Значение |
|---------|----------|
| Размер сообщения | ~2.4 KB |
| 100 сообщений | ~240 KB |
| 1000 сообщений | ~2.4 MB |
| Парсинг 1000 сообщений | <10ms |
| Реальный inbox (106 msgs) | 256 KB |
Проблема начнётся при 10000+ сообщений JSON.parse будет заметно медленнее. Для долгоживущих команд нужна архивация.
---
## Финальное решение (после 3 раундов ревью)
### Подход: Atomic write + messageId verify
Выбрана комбинация atomic write с постфактум-верификацией через `messageId`:
```typescript
// 1. Генерируем уникальный ID для нашего сообщения
const messageId = crypto.randomUUID();
const message: InboxMessage = {
from: 'user',
text,
timestamp: new Date().toISOString(),
read: false,
summary,
messageId,
};
// 2. Читаем текущий inbox
const existing = JSON.parse(fs.readFileSync(inboxPath, 'utf8'));
// 3. Добавляем сообщение
const updated = [...existing, message];
// 4. Atomic write (tmp + rename)
const tmpPath = inboxPath + '.tmp.' + process.pid;
fs.writeFileSync(tmpPath, JSON.stringify(updated, null, 2));
fs.renameSync(tmpPath, inboxPath);
// 5. Verify: перечитываем и проверяем messageId
const written = JSON.parse(fs.readFileSync(inboxPath, 'utf8'));
const found = written.some((m: InboxMessage) => m.messageId === messageId);
if (!found) {
// Потеря обнаружена — показать warning в UI, не silent fail
throw new Error(`Message ${messageId} lost during write`);
}
```
### Решения по итогам ревью
- **Полный CAS не нужен на MVP**: verify при следующем read достаточен для обнаружения потерь
- **messageId проверяется сразу после записи** (а не только при следующем read)
- **Не silent fail**: если сообщение потеряно UI показывает предупреждение пользователю
- **Retry не автоматический**: потеря крайне редка, ручная отправка достаточна на MVP
### Риски
| Риск | Вероятность | Митигация |
|------|-------------|-----------|
| Race condition: агент пишет одновременно | Низкая | Atomic write + verify |
| Потеря при race | Очень низкая | messageId verify warning в UI |
| Corrupted JSON | Практически 0 | Atomic write (tmp + rename) |
### Что не входит в MVP
- Автоматический retry при потере (добавить в Phase 2 при необходимости)
- Debounce записи (не нужен при редкой записи)
- Полный CAS с блокировкой (избыточно для данной частоты записей)