Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions docs/EXECUTION.md
Original file line number Diff line number Diff line change
Expand Up @@ -874,3 +874,24 @@ escopo da entrega atual nem a próxima tarefa aprovada.
quebrada.
- Próximo: #33 (Codex App Server autenticado), que também exige ensaio com conta
real, ou #41 (recuperação do estado após falha).

## Workspace — recuperação do estado após falha (#41)

- Data: 2026-09-28. Decisão no [ADR 0019](decisions/0019-state-lock-and-recovery.md).
- `src/workspace/private.rs` concentra pasta privada, trava do sistema
(`File::try_lock`/`flock`, liberada até em `kill -9`, arquivo mantido com PID),
recuperação de `*.new` remanescente (preservado como `*.new.recovered-<n>`,
nunca carregado nem apagado) e escrita atômica com fsync do arquivo e do
diretório. `ClaudeStore` e o `Store` da demo usam o módulo; ambos informam a
recuperação (aviso na sessão da demo e linha na conversa do Claude oculto).
- Evidências: testes do módulo (processo vivo recusado com PID, trava obsoleta
não bloqueia, symlink recusado, temporário remanescente bloqueia escrita até
ser recuperado, permissões 0600); teste da demo simulando writer morto entre
temporário e rename; scripts PTY verificam que a trava foi liberada via `flock`
não bloqueante; `check_claude_hidden_pty.py` faz `kill -9` da Bee, deixa um
`claude-native.new` parcial e reabre com `--resume` sem limpeza manual.
Latência medida está no ADR (máx. 130,6 ms para 16 MiB neste ambiente).
Checks canônicos, scripts PTY e `check_bundle.py` passaram localmente (Linux).
Autorrevisão.
- Limitações: sistemas de arquivos de rede não suportados; versões antigas não
respeitam a trava nova; persistência continua síncrona.
8 changes: 4 additions & 4 deletions docs/MANUAL_TESTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -777,10 +777,10 @@ permitir consultar a origem intacta. Perfil não autentica nem modifica conta re

Sair e executar o mesmo comando: histórico reaparece sem reenviar mensagens.
Segundo processo com mesma pasta deve recusar. Para simular crash, usar apenas
estado descartável e encerrar seu processo abruptamente: conferir que não resta
processo, inspecionar/remover manualmente `workspace.lock`, reabrir e verificar
interrupção registrada. `workspace.new` remanescente também requer inspeção;
não remover arquivos de outra sessão. Corrupção de JSON e projeto diferente devem
estado descartável e encerrar seu processo com `kill -9`: reabrir sem remover
nada e verificar a interrupção registrada. Criar um `workspace.new` qualquer na
pasta antes de reabrir: ele deve virar `workspace.new.recovered-<n>` e a sessão
mostrar o aviso, com o último estado completo carregado. Corrupção de JSON e projeto diferente devem
recusar preservando os bytes originais. Rascunho/tema não são restaurados.

## 48. Exportar a simulação e revisar origem (pendente)
Expand Down
6 changes: 3 additions & 3 deletions docs/decisions/0018-unified-workspace.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,9 +67,9 @@ API com cobrança separada como alternativa, pois contradiz a escolha do mantene
3. Validar troca entre agente/perfil com contexto revisado e origem preservada.
4. Lançamento conjunto somente com essas evidências. A demo não satisfaz esse aceite.

Snapshot é substituído por rename após sync do arquivo, mas não promete durabilidade
contra perda de energia (sem fsync de diretório), proteção de ancestrais contra
escritores hostis ou recuperação automática de trava após encerramento abrupto.
Snapshot é substituído por rename após sync do arquivo; trava, recuperação após
encerramento abrupto e fsync de diretório foram revistos no [ADR 0019](0019-state-lock-and-recovery.md).
Não há proteção de ancestrais contra escritores hostis.
I/O de persistência/exportação é síncrono; a demo não certifica responsividade
sob disco lento. Uma sessão ativa por pasta de estado, não trava global de checkout
para um futuro motor real. Colmeia estática, projeto explícito e tema não persistido.
Expand Down
48 changes: 48 additions & 0 deletions docs/decisions/0019-state-lock-and-recovery.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# 0019 — Trava do estado pelo sistema e recuperação de escrita

Status: adotado em 2026-09-28 para a issue #41. Substitui, para as pastas de
estado do workspace, a trava por arquivo criado com `create_new` descrita no
[ADR 0018](0018-unified-workspace.md).

## Contexto

`workspace.lock` e `claude-native.lock` eram criados com `create_new` e
apagados na saída. Depois de um kill ou crash, a próxima abertura falhava até o
usuário confirmar que não havia processo ativo e remover o arquivo à mão. Um
`*.new` deixado por uma escrita interrompida também bloqueava todas as gravações
seguintes. O snapshot era sincronizado, mas o diretório não.

## Decisão

- Trava consultiva do sistema (`File::try_lock`, `flock` em Unix) sobre o arquivo
de trava, mantida aberta enquanto o estado está aberto. O arquivo continua no
disco e guarda só o PID, para a mensagem de "em uso". O kernel solta a trava
quando o processo termina, inclusive por `kill -9`; um processo vivo nunca é
desalojado. Arquivo de trava que não seja arquivo comum (symlink) é recusado.
Havendo disputa, a abertura tenta por até 500 ms: um `fork` de outra thread do
mesmo processo mantém uma cópia do descritor até o `exec` (visto como falha
intermitente de 7 em 60 execuções dos testes paralelos; 0 em 60 com a espera).
- Com a trava obtida, um `*.new` remanescente é de uma escrita que parou antes do
rename: o snapshot confirmado continua sendo o último estado completo. O
remanescente é renomeado para `*.new.recovered-<nanossegundos>` na mesma pasta,
nunca carregado nem apagado, e a interface informa onde ficou.
- Escrita: arquivo temporário novo 0600, `sync_all`, rename sobre o snapshot e
`sync_all` do diretório. Em macOS, `sync_all` usa `F_FULLFSYNC`. Erro antes do
rename remove o temporário e preserva o snapshot anterior.
- Código compartilhado em `src/workspace/private.rs` pelas duas pastas de estado.

## Garantias e limites

- Depois de `save` retornar sucesso, o snapshot sobrevive a queda de energia em
sistemas de arquivos locais que honram `fsync`. Sistemas de arquivos de rede
podem não implementar `flock` nem essa durabilidade; não são suportados.
- Proteção contra outro processo Memory Bee, não contra escritores hostis com o
mesmo usuário nem contra alteração dos diretórios ancestrais.
- Versões anteriores apagavam a trava; uma versão antiga aberta junto com a nova
na mesma pasta não respeita o `flock`. Não misturar versões numa pasta.
- Persistência continua síncrona. Medição local (Linux, disco virtual, `cargo
test --release -- --ignored snapshot_latency`): estado Claude de 64 KiB com
mediana 0,6 ms e máximo 23,6 ms; estado da demo no limite de 16 MiB com
mediana 28,5 ms e máximo 130,6 ms. Discos lentos podem congelar a interface
por mais tempo; mover a escrita para outra thread fica para quando um estado
real grande existir.
13 changes: 8 additions & 5 deletions docs/specs/workspace.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,8 @@ Em todos os casos, se o CLI não sair em cinco segundos, o processo é encerrado

`claude-native.json` guarda projeto canônico, IDs e códigos de saída (até 128
sessões, 64 KiB), sem transcrição. A pasta é 0700 e arquivos são 0600 em Unix;
`claude-native.lock` impede segundo escritor. Após crash, verificar que não há
processo ativo antes de remover a trava antiga. `--resume` usa o último ID desta
`claude-native.lock` impede segundo escritor com trava do sistema, liberada até
em `kill -9`; não há limpeza manual ([ADR 0019](../decisions/0019-state-lock-and-recovery.md)). `--resume` usa o último ID desta
pasta; não reenvia prompt. A sessão nativa pode não existir quando se sai antes
de enviar uma mensagem. Isolamento de perfis e integração Codex estão pendentes.

Expand Down Expand Up @@ -181,9 +181,12 @@ e eventos. Limites: 16 MiB, 128 sessões, 32 perfis, 100 mil eventos por sessão
memória continua exportável. A sessão carregada como ativa é marcada interrompida,
sem reexecutar/reencaminhar mensagens. Tema e rascunho não são persistidos.

`workspace.lock` recusa segundo escritor. Após kill/crash, confirmar que não existe
outro processo usando a pasta antes de remover manualmente a trava. `workspace.new`
remanescente também exige inspeção manual; não apagar automaticamente. JSON inválido,
`workspace.lock` recusa segundo escritor com trava do sistema, liberada quando o
processo termina, inclusive por kill; o arquivo fica no disco e não exige remoção.
Um `workspace.new` remanescente (escrita interrompida) é preservado como
`workspace.new.recovered-<n>`, não é carregado, e a sessão registra o aviso; o
último snapshot completo é o carregado. Gravações sincronizam arquivo e diretório
([ADR 0019](../decisions/0019-state-lock-and-recovery.md)). JSON inválido,
projeto diferente, versão desconhecida e symlink de estado são recusados sem
substituir o arquivo original. Não é armazenamento cifrado nem trava de checkout.

Expand Down
28 changes: 25 additions & 3 deletions scripts/check_claude_hidden_pty.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,13 @@
import termios
import time

def assert_unlocked(path):
"""The lock file stays on disk; the process must have released it."""
with open(path) as handle:
fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
fcntl.flock(handle, fcntl.LOCK_UN)



FAKE = r'''#!/usr/bin/env python3
import json, os, signal, subprocess, sys
Expand Down Expand Up @@ -229,7 +236,7 @@ def untrusted(binary, project, state, env, quit_while_blocked):
drain_until_exit(master, proc, output, 10)
assert proc.returncode == 0, proc.returncode
assert termios.tcgetattr(slave) == before
assert not (state / 'claude-native.lock').exists()
assert_unlocked(state / 'claude-native.lock')
finally:
if proc.poll() is None:
proc.kill()
Expand Down Expand Up @@ -278,7 +285,7 @@ def until(token):
assert proc.returncode == 0, proc.returncode
assert b'CLAUDE ORIGINAL SHOULD STAY HIDDEN' not in output
assert termios.tcgetattr(slave) == before
assert not (state / 'claude-native.lock').exists()
assert_unlocked(state / 'claude-native.lock')
finally:
if proc.poll() is None:
proc.kill()
Expand Down Expand Up @@ -353,6 +360,21 @@ def main():
history_and_export(binary, project, state, env, True)
# History is shown from the transcript, never typed back into Claude.
assert stdin_log.read_text().splitlines() == ['/exit', '/exit']
# A killed Bee leaves its lock file and a partial write behind; the
# next start needs no manual cleanup and keeps the partial file aside.
master, slave, _, proc, output, until = spawn(binary, project, state, env, True)
try:
until(b'decrescente')
proc.kill()
proc.wait()
finally:
os.close(master)
os.close(slave)
(state / 'claude-native.new').write_text('partial')
history_and_export(binary, project, state, env, True)
kept = [p for p in state.iterdir() if p.name.startswith('claude-native.new.recovered-')]
assert len(kept) == 1 and kept[0].read_text() == 'partial'
assert json.loads((state / 'claude-native.json').read_text())['sessions']
with tempfile.TemporaryDirectory(prefix='bee-editor-') as temp:
root = Path(temp)
fake_dir = root / 'bin'
Expand All @@ -365,7 +387,7 @@ def main():
env = dict(os.environ, PATH=f'{fake_dir}:/usr/bin:/bin', BEE_BINARY=str(binary), BEE_FAKE_STDIN=str(root / 'stdin'))
editor(binary, project, root / 'state-a', dict(env, BEE_BRACKETED='1'), (24, 80), True)
editor(binary, project, root / 'state-b', env, (12, 40), False) # Documented minimum width.
print('PASS: Bee-only UI, hidden original output, hooks, deny/allow, Ctrl+Q denial, resume, blocked native setup via Ctrl+O, safe Ctrl+Q, history rehydration, verified export, multiline editor, scrolling, 40-column controls and terminal restoration')
print('PASS: Bee-only UI, hidden original output, hooks, deny/allow, Ctrl+Q denial, resume, blocked native setup via Ctrl+O, safe Ctrl+Q, history rehydration, verified export, multiline editor, scrolling, 40-column controls, recovery after kill and terminal restoration')


if __name__ == '__main__':
Expand Down
9 changes: 8 additions & 1 deletion scripts/check_claude_native_pty.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,13 @@
import termios
import time

def assert_unlocked(path):
"""The lock file stays on disk; the process must have released it."""
with open(path) as handle:
fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
fcntl.flock(handle, fcntl.LOCK_UN)



def one_run(binary, project, state, env, resume=False):
master, slave = pty.openpty()
Expand Down Expand Up @@ -45,7 +52,7 @@ def drain_until(token, seconds=5):
proc.wait(timeout=5)
assert proc.returncode == 0
assert termios.tcgetattr(slave) == before, 'terminal state not restored'
assert not (state / 'claude-native.lock').exists()
assert_unlocked(state / 'claude-native.lock')
return json.loads((state / 'claude-native.json').read_text())
finally:
if proc.poll() is None:
Expand Down
12 changes: 10 additions & 2 deletions scripts/check_workspace_pty.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,13 @@
import termios
import time

def assert_unlocked(path):
"""The lock file stays on disk; the process must have released it."""
with open(path) as handle:
fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
fcntl.flock(handle, fcntl.LOCK_UN)



def main():
repo = Path(__file__).resolve().parent.parent
Expand Down Expand Up @@ -64,7 +71,8 @@ def wait_for(predicate):
paste('synthetic task\nsecond line')
assert not snapshot()['sessions'][0]['events'], 'Paste submitted a message'
send('\r')
wait_for(lambda: snapshot()['sessions'][0]['events'][-1]['kind'] == 'approval')
# Saving is asynchronous to the keystroke; wait instead of indexing an empty list.
wait_for(lambda: snapshot()['sessions'][0]['events'][-1:] and snapshot()['sessions'][0]['events'][-1]['kind'] == 'approval')
send('\t')
send('\r') # Default deny.
wait_for(lambda: not snapshot()['sessions'][0]['running'])
Expand Down Expand Up @@ -96,7 +104,7 @@ def wait_for(predicate):
proc.wait(timeout=5)
assert proc.returncode == 0
assert termios.tcgetattr(slave) == before, 'Terminal was not restored'
assert not (state / 'workspace.lock').exists()
assert_unlocked(state / 'workspace.lock')
assert list(project.iterdir()) == [], 'Demo modified project'
print('PASS: paste, approval denial, confirmed export/schema/verify, resize, interruption, private state and terminal restoration')
finally:
Expand Down
6 changes: 6 additions & 0 deletions src/tui/claude_native.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1707,6 +1707,12 @@ pub fn run_hidden(
}
let mut view = HiddenView::new(Instant::now());
view.resume = resume;
if let Some(kept) = &store.recovered {
view.push(format!(
"Gravação anterior interrompida; estado completo carregado e parcial preservado em {}.",
kept.display()
));
}
let mut status = None;
let mut quit_at = None;
let mut dirty = true;
Expand Down
38 changes: 31 additions & 7 deletions src/tui/draft.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,18 +68,27 @@ impl Draft {
pub fn end(&mut self) {
self.cursor = self.line_end();
}
/// Byte offset of the `column`-th char of the line starting at `start`.
/// Byte offset in the line starting at `start` whose display column is
/// closest to `column` without passing it (wide chars take two cells).
fn at_column(&self, start: usize, column: usize) -> usize {
let line = &self.text[start..];
let line = &line[..line.find('\n').unwrap_or(line.len())];
start
+ line
.char_indices()
.nth(column)
.map_or(line.len(), |(i, _)| i)
let mut used = 0;
for (i, c) in line.char_indices() {
let w = c.width().unwrap_or(0);
if used + w > column {
return start + i;
}
used += w;
}
start + line.len()
}
/// Display column of the cursor, in terminal cells.
fn column(&self) -> usize {
self.text[self.line_start()..self.cursor].chars().count()
self.text[self.line_start()..self.cursor]
.chars()
.map(|c| c.width().unwrap_or(0))
.sum()
}
/// Returns false on the first line, so the caller can use Up elsewhere.
pub fn up(&mut self) -> bool {
Expand Down Expand Up @@ -185,6 +194,21 @@ mod tests {
assert_eq!(d.text(), ">primeir\nb\nerceira");
}

#[test]
fn vertical_moves_keep_the_visual_column_with_wide_chars() {
let mut d = draft("漢字漢\nabcdef");
d.left();
d.left();
assert_eq!(d.layout(80).1, (1, 4));
assert!(d.up());
assert_eq!(d.layout(80).1, (0, 4), "lands after two wide chars");
assert!(d.down());
assert_eq!(d.layout(80).1, (1, 4));
d.left();
assert!(d.up());
assert_eq!(d.layout(80).1, (0, 2), "never inside a wide char");
}

#[test]
fn paste_normalizes_breaks_and_drops_controls() {
let mut d = draft("a\r\nb\rc\x1b[31m\td");
Expand Down
Loading
Loading