Skip to content

feat(session): end a turn that has stopped producing anything - #103

Merged
filipeforattini merged 1 commit into
mainfrom
feat/turn-inactivity-watchdog
Sep 4, 2026
Merged

feat(session): end a turn that has stopped producing anything#103
filipeforattini merged 1 commit into
mainfrom
feat/turn-inactivity-watchdog

Conversation

@filipeforattini

@filipeforattini filipeforattini commented Sep 4, 2026

Copy link
Copy Markdown

Primeira tranche do plano. Um turno que fica em silêncio não tinha nada que o encerrasse: o provider aceita a requisição, não manda nada, e a sessão fica ocupada até alguém perceber — trinta minutos de barra rodando, tudo que você digita empilhando atrás, e a conta das tentativas.

O que ele mede

Silêncio, não tempo decorrido. Um prazo por relógio também mata turno saudável longo; só inatividade distingue turno morto de turno lento. Foi a escolha do hermes, o único dos cinco harnesses estudados desenhado para rodar sem ninguém olhando.

Duas coisas deliberadamente não contam como silêncio:

  • Ferramenta rodando. Ferramentas executam dentro do SDK do provider e não emitem absolutamente nada enquanto trabalham. Sem esse guard, um cargo build de meia hora é indistinguível de um provider que sumiu. O teste que prova isso é o mais importante do PR, e verifiquei que ele falha sem o guard: sem ele, o watchdog mata o turno com a ferramenta em execução.
  • Permissão aguardando resposta. Alguém decidindo se aprova um comando não é um turno travado.

Onde ele age

Isto é um julgamento sobre quem está presente, e foi a sua decisão. Na TUI e no app, ele reporta e não encerra: tem gente sentada ali, que lê o aviso, e apertar Esc é uma escolha dela. Num run script, num editor falando ACP ou num job agendado, ninguém vai apertar nada nunca — ali o silêncio é a falha inteira e encerrar é o único ato útil.

Consequência honesta: o travamento que você vê na TUI passa a ser visível, não encerrado. Se na prática isso se mostrar errado, é uma configuração de distância.

Detalhes que custaram para acertar

  • O motivo é escrito antes da interrupção. Todo escritor posterior naquele caminho usa ??=, então quem chega primeiro decide o que a mensagem diz. Sem isso, um encerramento por watchdog fica idêntico a você ter apertado Esc.
  • O cancelamento é destacado de propósito. Cancelar interrompe o próprio fiber do watchdog no meio do caminho, e a parte que devolve a sessão para ociosa roda depois desse ponto. Chamar inline deixaria a sessão presa em busy — exatamente o bug que este PR existe para matar.
  • A TUI passou a se identificar como REDCODE_CLIENT=tui. O servidor roda dentro do worker dela e, até agora, não conseguia se distinguir de um run headless: ambos eram cli.
  • A cadência sai dos próprios limiares, com piso e teto. Não faz sentido olhar a cada 15 s se o limite é de segundos, nem a cada 100 ms se é de minutos.

Verificação

  • packages/redcode/src/session/stall.ts é puro, então cada ramo tem teste sem timer, sem fiber e sem provider: 9 testes.
  • Dois testes de integração contra o provider falso: um prova que o turno silencioso é encerrado e que o motivo chega na mensagem; o outro prova que um turno com ferramenta rodando é deixado em paz. Ambos verificados em vermelho antes do verde.
  • Suítes: sessão 421, effect/tool/config 290, core 1143, todas verdes. bun typecheck nos 31 pacotes.

Ainda sem cobertura

Chamadas ao modelo fora de um passo — compactação, título, resumo — não passam por este handle e continuam sem vigilância. Está anotado no plano como trabalho seguinte, junto com o prazo por ferramenta, que é a próxima tranche que você escolheu.

https://claude.ai/code/session_01U29Yk1UscZJ5ZVBXV1Sn8b


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

A turn that goes quiet had nothing to stop it. The provider accepts the request,
sends nothing, and the session sits busy until someone notices — thirty minutes
with a spinner, everything typed queueing behind it, and a bill for the retries.

The watchdog measures silence, not elapsed time. A wall-clock deadline also ends
healthy long turns; only inactivity distinguishes a dead turn from a slow one.
Two things are deliberately not silence: a tool that is running, since tools
execute inside the provider SDK and emit nothing at all while they work, and a
permission waiting on a person. Either would otherwise make a long build look
exactly like a provider that went away.

Where it acts is a judgement about who is present. In the TUI and the desktop
app it reports and leaves the turn alone: someone is sitting there, can read the
warning, and pressing escape is their call to make. In a scripted run, an editor
speaking ACP or a scheduled job nobody will ever press anything, so silence is
the whole failure and ending it is the only useful act.

- `packages/redcode/src/session/stall.ts` — the decision, kept pure so every
  branch is testable without timers, fibers or a provider.
- The abort writes its reason onto the message before interrupting. Every later
  writer on that path guards with `??=`, so whoever arrives first decides what
  the message says; otherwise this would read as an ordinary user interrupt.
- The cancel is detached on purpose: cancelling interrupts the watchdog's own
  fiber partway through, and the part that returns the session to idle runs
  after that point.
- The TUI now identifies itself as `REDCODE_CLIENT=tui`. The server runs inside
  its worker and could not otherwise tell itself apart from a scripted run.

Claude-Session: https://claude.ai/code/session_01U29Yk1UscZJ5ZVBXV1Sn8b
@filipeforattini
filipeforattini merged commit de65b16 into main Sep 4, 2026
7 checks passed
@filipeforattini
filipeforattini deleted the feat/turn-inactivity-watchdog branch September 4, 2026 03:41
filipeforattini added a commit that referenced this pull request Sep 4, 2026
The turbo `test` task was declared for five names, one of which — `opencode#test`
— stopped matching anything at the rename, so the main package and the TUI have
not run in CI since. Declaring it once covers all 30 suites.

Turning them on surfaced two real defects, both mine:

- mise detection matched any `mise/installs` path, so a machine whose Bun comes
  from mise called every install mise-managed;
- a source-text assertion pinned the exact shape of the worker's `env` literal
  and broke when #103 added a key, while saying nothing about whether the
  environment still reaches the worker.

Keep `^build` only on the four tasks that already had it: the generic task made
every test run build the release binaries.

Claude-Session: https://claude.ai/code/session_01U29Yk1UscZJ5ZVBXV1Sn8b
filipeforattini added a commit that referenced this pull request Sep 4, 2026
* ci: run every package's tests, not five of them

turbo.json declared `test` for five task names, one of which — `opencode#test`
— stopped matching anything at the rename. The main package and the TUI have
not run in CI since: a regression I shipped in #96 broke three TUI tests and
nothing noticed until I ran them by hand.

Declare the task once so every package that has a test script runs it, and
raise the job timeout to match the new scope.

Claude-Session: https://claude.ai/code/session_01U29Yk1UscZJ5ZVBXV1Sn8b

* ci: run every package's tests, and fix the two failures that hid there

The turbo `test` task was declared for five names, one of which — `opencode#test`
— stopped matching anything at the rename, so the main package and the TUI have
not run in CI since. Declaring it once covers all 30 suites.

Turning them on surfaced two real defects, both mine:

- mise detection matched any `mise/installs` path, so a machine whose Bun comes
  from mise called every install mise-managed;
- a source-text assertion pinned the exact shape of the worker's `env` literal
  and broke when #103 added a key, while saying nothing about whether the
  environment still reaches the worker.

Keep `^build` only on the four tasks that already had it: the generic task made
every test run build the release binaries.

Claude-Session: https://claude.ai/code/session_01U29Yk1UscZJ5ZVBXV1Sn8b

* test: fix what turning the suites on revealed

- `redcode run`: #98 made an unknown finish continue the turn instead of ending
  it in silence, which is the recovery we want; the two tests still pinned the
  old shape. They now assert the recovery and what must not change with it.
- `httpapi-codegen`: expectations pinned "/" separators and read a fixture
  directory through `URL.pathname`, which is "/C:/..." on Windows. Neither had
  ever run there.

Claude-Session: https://claude.ai/code/session_01U29Yk1UscZJ5ZVBXV1Sn8b

* test: make the newly-running suites pass on machines other than a quiet Linux box

Everything here failed for a reason unrelated to what the test was checking:

- TUI frame captures gave up after 125 ms and command registration after 250 ms,
  so the whole-suite run read a blank frame as a broken component;
- `tool.write` asserted 0644, which is only what a umask of 022 produces —
  forcing the mode would be worse than the bug, so the test now follows umask;
- the codegen fixture compared bytes across a CRLF checkout;
- the webfetch converter test builds 10,000 nested divs on purpose and the
  embedded-server suite boots a server per test, both against a 5 s default.

Claude-Session: https://claude.ai/code/session_01U29Yk1UscZJ5ZVBXV1Sn8b

* test: resolve effect through the module graph, not this package's node_modules

`packages/client/node_modules` does not exist on the Windows runner — the
installer hoists to the workspace root — so the file threw at import time and
the whole suite failed before a test ran.

Claude-Session: https://claude.ai/code/session_01U29Yk1UscZJ5ZVBXV1Sn8b

* test: stop pinning POSIX separators in TUI path assertions

Both tests build paths with `path`, and both asserted the result with a
hard-coded "/". They described a POSIX machine, which is the only kind they had
ever run on.

Claude-Session: https://claude.ai/code/session_01U29Yk1UscZJ5ZVBXV1Sn8b

* test: build the SDK before the CLI tests, and call quoted binaries the way PowerShell needs

- `@reddb-io/redcode#test` spawns the real CLI, which reads generated SDK
  sources; without `^build` it read a file that had not been generated yet.
- One shell test built a quoted command without the call operator that the
  helper beside it already applies, so PowerShell parsed the path as a string
  and the first flag as a syntax error.

Claude-Session: https://claude.ai/code/session_01U29Yk1UscZJ5ZVBXV1Sn8b
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant