samagotchi 0.20.0 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +77 -1
  3. data/docs/architecture.md +5 -3
  4. data/docs/cli.md +2 -1
  5. data/docs/configuration.md +11 -0
  6. data/docs/plugins.md +22 -8
  7. data/docs/sessions.md +3 -3
  8. data/docs/sub-agent.md +11 -5
  9. data/lib/samagotchi/answer_command.rb +7 -3
  10. data/lib/samagotchi/bridge/sse_writer.rb +32 -0
  11. data/lib/samagotchi/bridge/turn_accumulator.rb +10 -3
  12. data/lib/samagotchi/bridge.rb +16 -4
  13. data/lib/samagotchi/bridge_client.rb +33 -9
  14. data/lib/samagotchi/bundle_command.rb +6 -0
  15. data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
  16. data/lib/samagotchi/bundles/loop-guard/plugin.rb +61 -5
  17. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  18. data/lib/samagotchi/cli/parent_wait.rb +9 -2
  19. data/lib/samagotchi/continue_offer.rb +34 -9
  20. data/lib/samagotchi/engine.rb +47 -4
  21. data/lib/samagotchi/events.rb +1 -1
  22. data/lib/samagotchi/llm/chat_loop.rb +1 -1
  23. data/lib/samagotchi/llm/errors.rb +31 -0
  24. data/lib/samagotchi/llm/openai_chat.rb +33 -7
  25. data/lib/samagotchi/llm/prompt_cache.rb +83 -0
  26. data/lib/samagotchi/log_subscriber.rb +4 -2
  27. data/lib/samagotchi/parent_report.rb +24 -7
  28. data/lib/samagotchi/question_desk.rb +1 -7
  29. data/lib/samagotchi/reply_wait.rb +12 -2
  30. data/lib/samagotchi/session_manager.rb +26 -9
  31. data/lib/samagotchi/session_metrics.rb +6 -4
  32. data/lib/samagotchi/steer.rb +1 -1
  33. data/lib/samagotchi/terminal_ui/attached_loop.rb +18 -9
  34. data/lib/samagotchi/terminal_ui/event_renderer.rb +1 -1
  35. data/lib/samagotchi/terminal_ui/formatting.rb +7 -2
  36. data/lib/samagotchi/terminal_ui/live_region.rb +41 -2
  37. data/lib/samagotchi/terminal_ui/reline_seam.rb +10 -0
  38. data/lib/samagotchi/turn_state.rb +22 -0
  39. data/lib/samagotchi/version.rb +1 -1
  40. data/lib/samagotchi/web/public/app.js +34 -7
  41. data/lib/samagotchi/web/public/index.html +7 -0
  42. data/lib/samagotchi/web/public/notifications.js +8 -3
  43. data/lib/samagotchi/web/public/notify.js +49 -7
  44. data/lib/samagotchi/web/public/timing.js +5 -3
  45. data/lib/samagotchi/web/public/turn_events.js +16 -0
  46. metadata +2 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c3b1a6860a8c22ed75259438c755633283385c12ffbc28a8fcb4c817f59114c5
4
- data.tar.gz: 24f7c45ebe336077bc2d9a1e670d3313b364c48d5b28aef3624593705236f29e
3
+ metadata.gz: 9019626f95da411558ec5350303433de58fd07f9db6f9e22323856e4e2ed31a0
4
+ data.tar.gz: 015df1a469fb64ff5556ce6f683ee23abf039865a34584fff07e3f3c32e4724e
5
5
  SHA512:
6
- metadata.gz: 9f1d9dec24344b223170728f80629a76d1db18697f752b2ce7135cf3569428a902f1cc18589a928184f7809bcbe3a20289281cd4cae6324bdd3f5bb1192cf860
7
- data.tar.gz: 8bf9a7cf6dbaac98a7583987be61d98e06ddd3ab6a0b6f36423ae41a6aa530ef42479fa28139ad89b8253e61edce1a57013dd27d0a9bcbf5b31c080cea6f8801
6
+ metadata.gz: 1bd652f5d5c2d549dce4eba12cc6577f34cb80ae2a95ea4d63492b4581ed510a5f4eeb8b741f86da7af39198dbfc4981e5d6e7d019dfff7d90f8c0a19722cd6b
7
+ data.tar.gz: fe6e4cbc412b112d157ad16a1f176fa1308ed094498eb982770b5a6b87859a89c9d544b9c0791d953e0823e51e09396f13e484eccdb240cdd0a0daad8dfe3f8e
data/CHANGELOG.md CHANGED
@@ -8,6 +8,80 @@ and commands may change between minor versions. How releases are made:
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [0.22.0] - 2026-10-04
12
+
13
+ ### Added
14
+
15
+ - Claude models on OpenAI-compatible hosts (OpenRouter) use Anthropic's prompt cache: chat requests mark the
16
+ system message and the last message with `cache_control`, so each step reads the earlier prompt from the
17
+ cache (about a tenth of the price) instead of paying for all of it again. The log line shows `cache=on`.
18
+
19
+ ### Changed
20
+
21
+ - An HTTP 402 is no longer a "rejected the request" bad request. OpenRouter's "would exceed your available
22
+ credits given your current in-flight requests" (credit reserved by another running request) is retried like a
23
+ 429, 20s apart (about 100s with the default `retry.max`), instead of failing the turn at once; any other 402
24
+ fails as `out of credits on host <name>: …; add credits, then send again`, with `error_kind` `credits`.
25
+ - `chi answer --option Continue --text "…"` is accepted: the text joins the continued turn as a steer (marked as the parent agent's for `chi answer`), instead of being refused.
26
+ - The Continue answer's text keeps its line breaks in the continued turn's steer, and it is dropped (logged
27
+ `steer_dropped why=not_begun`) when the continue turn fails before it begins or a typed `/continue no` answered
28
+ the offer first, instead of joining whatever turn ran next.
29
+ - `chi answer --format json` says `"answered_here": false` when the question was no longer open (answered
30
+ elsewhere, or another one waits now): it still waits for what comes next, but the JSON no longer reads as if
31
+ the given answer went in.
32
+ - `chi send --wait` and `chi answer` with `--format json` say why a turn stopped: a failed turn adds `error_kind`
33
+ (the provider error's kind, e.g. `credits`, `server`) and `retryable`, a canceled one `cancel_reason` (`user`,
34
+ `hook`, `ctrl_c`, `manual`) and `stopped_by` (the hook, e.g. `loop-guard`), when known. The session's
35
+ `last_turn` records them.
36
+
37
+ Update with `chi update` (no bundle versions moved). Restart `chi web` and running sessions afterwards (`chi sessions restart ID`).
38
+
39
+ ## [0.21.0] - 2026-10-04
40
+
41
+ ### Added
42
+
43
+ - The debug log's generation_completed line names the OpenRouter provider that served it (provider=).
44
+
45
+ ### Fixed
46
+
47
+ - loop-guard cuts thinking that loops in short sentences ("I'll write it. Go. OK. Writing."),
48
+ which it skipped before: such a loop ran for minutes to the provider's output cap. New settings
49
+ `thinking.short_run` (24) and `thinking.short_distinct` (6).
50
+ - loop-guard's thinking watch no longer goes blind for the rest of a generation after an empty thinking
51
+ chunk (a FrozenError, logged as `plugin_hook_failed`), so it now sees every generation's thinking.
52
+ - loop-guard no longer stops a whole turn for a thinking loop that comes long after the model recovered from
53
+ its first one: after `thinking.forget_after` (10) steps with no loop, the next loop is cut and retried again.
54
+ - A turn a plugin stopped now says who did it, `✕ turn stopped by loop-guard` (the web: `✕ stopped by
55
+ loop-guard`), instead of `✕ canceled (by a hook)`, also after a reload of the web page.
56
+ - An attached `/exit` the worker reads only after the TUI stopped waiting (a frozen or sleeping worker) no
57
+ longer stops it later: the request is dropped, as late turns and commands are.
58
+ - The attached `/exit` (and `/exit --delete`, `/archive`) in an empty session no longer claims the session is
59
+ already gone: a note arriving before the worker stops keeps it, so it says it will be discarded if nothing
60
+ arrives.
61
+ - Archiving a session (`/archive`, `chi sessions archive`, the web) with a prompt still queued or a step-limit
62
+ question open is refused with a message saying so, instead of stopping the worker and leaving the queued
63
+ prompt to run on the next resume.
64
+ - `/exit` right after a `/detach` and `chi --attach` no longer says "another UI is attached" and leaves the worker
65
+ up: the worker notices a closed stream at once instead of at its next 15-second heartbeat.
66
+ - Leaving chi's terminal UI (`/exit`, `/detach`, Ctrl-D) no longer can leave a stray cursor report such as
67
+ `^[[12;1R` at the shell prompt: input nobody read is dropped before the terminal is handed back.
68
+ - Joining a running turn (web or `chi --attach`) while the model is holding before its first token now shows
69
+ the step it's on, as a live view does, instead of the previous step still live.
70
+ - Web: sending into a session whose worker was already up (but the page didn't know) no longer draws
71
+ that worker's earlier turns a second time: the page re-reads the session instead.
72
+ - Web without the session events stream (fetched list): a re-fetched list now badges and notifies like the
73
+ live list does.
74
+ - Web: a session deleted while the page had lost the session events stream (or between two fetches of the
75
+ list) no longer keeps its question or card counted in the tab title's badge until the tab comes to front.
76
+ - Web on a phone: the session footer's archive, stop and delete buttons are icons (the words stay as their
77
+ accessible names and tooltips), so the status and `mem N` chips beside them are in sight again.
78
+ - Web: with several chi tabs in the background, a session that needs you shows one OS notification, not one
79
+ per tab (every tab still counts it in its title).
80
+ - chi web: a session stream whose worker took the connection and froze before answering no longer holds the
81
+ request forever; it gives up after 5 s per attempt.
82
+
83
+ Update with `chi update`: loop-guard moves to 0.3.1 (short-sentence loops, forget_after, the blind-watch fix). Restart `chi web` and running sessions afterwards (`chi sessions restart ID`).
84
+
11
85
  ## [0.20.0] - 2026-10-03
12
86
 
13
87
  ### Added
@@ -1199,7 +1273,9 @@ and long-lived sessions.
1199
1273
  - A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
1200
1274
  hotkey panel that send selected text or the clipboard to your sessions.
1201
1275
 
1202
- [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.20.0...HEAD
1276
+ [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.22.0...HEAD
1277
+ [0.22.0]: https://github.com/dm1try/samagotchi/compare/v0.21.0...v0.22.0
1278
+ [0.21.0]: https://github.com/dm1try/samagotchi/compare/v0.20.0...v0.21.0
1203
1279
  [0.20.0]: https://github.com/dm1try/samagotchi/compare/v0.19.0...v0.20.0
1204
1280
  [0.19.0]: https://github.com/dm1try/samagotchi/compare/v0.18.1...v0.19.0
1205
1281
  [0.18.1]: https://github.com/dm1try/samagotchi/compare/v0.18.0...v0.18.1
data/docs/architecture.md CHANGED
@@ -170,7 +170,8 @@ higher-level events so UIs get clean turn boundaries without inferring them:
170
170
 
171
171
  - `:turn_started` — `{ session_id:, prompt: }`
172
172
  - `:turn_completed` — `{ result: }` (the final `LLM::ModelResult`)
173
- - `:turn_canceled` — `{ cancellation_reason: }`
173
+ - `:turn_canceled` — `{ cancellation_reason:, cancelled_by: }` (`cancelled_by`: the bundle or hook label that
174
+ stopped it, for `cancellation_reason: :hook`; else nil)
174
175
 
175
176
  Every event is a `Hash` with a `:type` symbol key; the sink must not raise (the Engine
176
177
  rescues sink errors). A new UI (web, API) supplies its own `on_event` and
@@ -391,8 +392,9 @@ output) and cancel. Cancel closes the in-flight socket from the
391
392
  `CancellationController` listener, so it works on any thread.
392
393
 
393
394
  **Errors.** A failed request raises an `LLM::ProviderError` of one kind:
394
- `ConnectionError` (`RetryExhausted`), `RateLimited`, `ServerError`, `AuthError`,
395
- `BadRequest` (`context_overflow?`) or `ProtocolError`. Engine keeps the turn's
395
+ `ConnectionError` (`RetryExhausted`), `RateLimited` (`CreditsHeld`: a 402 about
396
+ credit held by in-flight requests, retried after 20 s), `OutOfCredits` (any other
397
+ 402), `ServerError`, `AuthError`, `BadRequest` (`context_overflow?`) or `ProtocolError`. Engine keeps the turn's
396
398
  conversation (the prompt plus completed tool iterations) and emits
397
399
  `:turn_failed` with `error_kind:`, `retryable:`, `host:` and a one-line
398
400
  `summary:`, which the REPL, the attached TUI and the web show.
data/docs/cli.md CHANGED
@@ -844,7 +844,8 @@ Behavior notes:
844
844
  `partial progress kept; !rollback restores the pre-turn state` under it (a canceled continue is back where it
845
845
  started). A failed turn ends with `✕ turn failed: <summary> · 2.0s` and a dim `prompt restored for retry`. The
846
846
  REPL and attached mode say the same; the web says `✕ canceled (Ctrl-C)` (`stopped` for its Stop button,
847
- `by a hook` for a hook's).
847
+ `by a hook` for a hook's). A turn a bundle's plugin stopped names it: `✕ turn stopped by loop-guard` (the web:
848
+ `✕ stopped by loop-guard`).
848
849
  - Visible text the canceled request had streamed stays in the conversation, marked `[interrupted]`, so the next
849
850
  message (or a continue) picks up from the half-finished reply; the canceled request's thinking and any unfinished
850
851
  tool call are dropped.
@@ -513,6 +513,12 @@ Every chat request carries the session's id as a `Session-Id` header (next to `U
513
513
  gateway that spreads requests over several providers can key on it to keep one conversation on one provider, so
514
514
  prompt caches hit and every turn is answered by the same model. Servers that don't know the header ignore it.
515
515
 
516
+ Chat requests for a Claude model (an id containing `claude`, such as `anthropic/claude-sonnet-5.5`) on a remote
517
+ OpenAI-compatible host get two prompt-cache breakpoints (`cache_control` on the system message and on the last
518
+ message), so each step reads the earlier prompt from Anthropic's cache instead of paying for it again; the debug
519
+ log's line shows `cache=on`. Nothing to configure. A provider behind a gateway may still not cache: check the
520
+ cached tokens in `/stats`.
521
+
516
522
  ## Llama Model Routing
517
523
 
518
524
  To explicitly route requests to a named model in llama.cpp, set:
@@ -607,6 +613,9 @@ Transient network failures are retried automatically with exponential backoff.
607
613
  - Retry scope: transient network errors (timeouts, reset connections, EOF/socket reachability failures),
608
614
  HTTP 429 and HTTP 500/502/503/504/529. A `Retry-After` header replaces the backoff delay; one longer than
609
615
  60s is not waited out and the error is reported instead.
616
+ - An HTTP 402 about credit held by in-flight requests (OpenRouter reserves credit for each running request, so
617
+ one session's long request can make another's fail) is retried like a 429, 20s apart unless `Retry-After`
618
+ says otherwise: with the default `retry.max` that is about 100s. With `retry.max: 0` it fails at once.
610
619
  - A refused connection (nothing listening) is not retried: the turn fails at once with `can't reach host <name> at
611
620
  <address> (connection refused) — is the server running?`.
612
621
  - A stream that has already produced output is never retried (the retry would repeat it); it fails the turn.
@@ -653,6 +662,8 @@ message (before, a failed llama.cpp `/completion` ended the turn as
653
662
  |---|---|---|
654
663
  | connection | reset, timed out, dropped mid-stream; refused | yes (network retry), not mid-stream; refused: no |
655
664
  | rate limited | HTTP 429 | yes, honouring `Retry-After` |
665
+ | credits held | HTTP 402 "… in-flight requests" | yes, after 20s (or `Retry-After`) |
666
+ | credits | any other HTTP 402: out of credits | no |
656
667
  | server | HTTP 5xx, llama.cpp's mid-stream `error:` event | 500/502/503/504/529 only |
657
668
  | auth | HTTP 401/403 | no |
658
669
  | bad request | other 4xx; a prompt larger than the context window, whatever the status | no |
data/docs/plugins.md CHANGED
@@ -831,16 +831,22 @@ minutes, until the provider's output cap ends it with no answer. loop-guard
831
831
  watches the thinking while it streams ([Watching the
832
832
  stream](hooks.md#watching-the-stream)) and cuts it:
833
833
 
834
- - The thinking is cut into sentences; short ones (under `min_words`) are
835
- skipped. Two sentences count as the same when their words and word pairs
836
- mostly match (`similarity`), so a reworded round ("Hmm, …", a synonym, a
837
- swapped clause) still counts.
834
+ - The thinking is cut into sentences. Two sentences of `min_words` or more
835
+ count as the same when their words and word pairs mostly match
836
+ (`similarity`), so a reworded round ("Hmm, …", a synonym, a swapped
837
+ clause) still counts.
838
838
  - A loop is a cycle of 1 to `max_period` sentences seen `repeats` times in a
839
839
  row, over at least `min_span_sentences` sentences and `min_span_chars`
840
840
  chars; or one sentence `max_same` times anywhere in the generation. One
841
841
  sentence over and over must be near-identical (`similarity` + 0.4), and a
842
842
  longer cycle must hold different sentences: a list of templated sentences
843
843
  ("Now I need to open the file <path> and …" for 14 paths) is no loop.
844
+ - Short sentences (under `min_words`) are a loop of their own when
845
+ `short_run` of them come in a row with at most `short_distinct` different
846
+ ones among them ("I'll write it. Go. OK. Writing. Go. OK."). Normal
847
+ thinking has short sentences too ("Hmm.", "Fine."), but spread out or all
848
+ different (code lines); a longer sentence ends the run, and lines inside a
849
+ ```` ``` ```` code block don't count.
844
850
  - Nothing triggers before `min_chars` of thinking. The watch sees a loop
845
851
  within one batch (2000 chars, or a second) of its third cycle.
846
852
 
@@ -851,11 +857,16 @@ What happens (`action: retry`, the default):
851
857
  `loop-guard> thinking repeats itself (3 sentences ×3, 4k chars, 8 s): cut`
852
858
  and `↻ cut by loop-guard, asking again (1/1)`.
853
859
  2. If the retry loops too, the turn is stopped ("stopped the turn: …", "✕
854
- turn canceled (hook)") with a card that quotes the repeated sentences.
860
+ turn stopped by loop-guard") with a card that quotes the repeated
861
+ sentences.
862
+ 3. A loop is forgotten after `forget_after` good steps in a row (generations
863
+ with no loop): a model that recovered and made progress gets its next
864
+ loop cut and retried again, not the turn stopped. `forget_after: 0`
865
+ never forgets: the turn's second loop stops it.
855
866
 
856
867
  The cut uses the `retry.empty_answer` budget: with `retry.empty_answer: 0`
857
- the first loop ends the turn as cancelled (hook), with the notice and no
858
- card. `action: stop` stops the turn at the first loop; `action: notify` only
868
+ the first loop ends the turn as stopped by loop-guard, with the notice and
869
+ no card. `action: stop` stops the turn at the first loop; `action: notify` only
859
870
  warns, once per generation.
860
871
 
861
872
  ```yaml
@@ -871,7 +882,10 @@ bundles:
871
882
  min_span_sentences: 6 # a loop spans at least this many sentences…
872
883
  min_span_chars: 600 # …and this many chars
873
884
  max_same: 8 # one sentence this many times in one generation is a loop
874
- min_words: 5 # shorter sentences are ignored
885
+ min_words: 5 # shorter sentences only count toward a short run
886
+ short_run: 24 # this many short sentences in a row…
887
+ short_distinct: 6 # …with at most this many different ones is a loop
888
+ forget_after: 10 # good steps in a row that forget a loop; 0: never
875
889
  ```
876
890
 
877
891
  Not watched:
data/docs/sessions.md CHANGED
@@ -15,7 +15,7 @@ A session is deleted if **expired by age OR overflow by count** (unless `keep_st
15
15
 
16
16
  **Worker idle exit:** a background worker (plain `chi`, Web UI sessions) exits after `SAMAGOTCHI_SESSION_IDLE_EXIT_MINUTES` (`session.idle_exit_minutes`, default `30`, `0`=never) with no turn running or queued, no client on its stream (an open web tab or attached terminal keeps it), no reminder registered and no continue offer (the step-limit question) waiting for an answer. It removes `bridge.json` and frees `owner.lock`; the next prompt or `--attach` wakes a new worker (`WorkerIdleExit`, `SessionManager.run_session_loop`). A client can also ask the worker to exit right away (Bridge `POST /session/:id/exit`; `/exit` in an attached terminal does, `/detach` doesn't): the same rules apply without the timeout (even with `0`), and the asker's own stream doesn't count. The reply names what keeps it up. The session's status stays as it was, not `stopped`. With `"restart": true` (a worker whose sidecar lists the `restart` feature) the worker hands the session to a new worker on the newest chi installed: it leaves, then starts its successor once its lock is free, keeping even an empty session and writing no recap. Clients' streams don't hold a restart (a web tab and an attached terminal reconnect to the new worker); what would be lost does: a turn running or queued, a continue offer, a question or approval waiting, reminders, a `/btw` (or another anytime command) still running, a delegate's approval relay open or settled in the last 10 minutes, and background tasks this conversation started that still run (`WorkerIdleExit#hold_for_restart`; 409 `held` with `reason`). What lives only in the worker's memory goes: the event history a late client replays, the kept `/btw` answers, a plugin's in-memory state.
17
17
 
18
- **Empty sessions:** a session nothing happened in is deleted when it is left, so `chi` → `/exit` leaves no trace. Empty means: no messages, no turn tried (a failed turn leaves a `last_prompt` and keeps it), no memory used, no pending question, nothing in `input/`, `notes/` or `images/`, nothing in its dir beyond the worker's skeleton, mode `assist`, and the default model. A session on another model (`/model`, `--model`) counts as prepared and stays (`SessionManager.discardable?`). The worker checks as it idle-exits or leaves on a client's request and deletes it once its lock is free, checking again first. The attached `/exit` then says `Detached; the session was empty, so it is discarded.` The REPL (`--no-shared`) does the same at `/exit` or Ctrl-D. The retention sweep catches those killed first: empty sessions over an hour old with no live owner, whatever `DAYS`/`MAX`, and skeleton-only dirs with no `*.json`. `session.keep_empty: true` (env `SAMAGOTCHI_SESSION_KEEP_EMPTY`) turns all of it off.
18
+ **Empty sessions:** a session nothing happened in is deleted when it is left, so `chi` → `/exit` leaves no trace. Empty means: no messages, no turn tried (a failed turn leaves a `last_prompt` and keeps it), no memory used, no pending question, nothing in `input/`, `notes/` or `images/`, nothing in its dir beyond the worker's skeleton, mode `assist`, and the default model. A session on another model (`/model`, `--model`) counts as prepared and stays (`SessionManager.discardable?`). The worker checks as it idle-exits or leaves on a client's request and deletes it once its lock is free, checking again first. The attached `/exit` then says `Detached; the session is empty: it will be discarded if nothing arrives before its worker stops.` (a note or prompt landing first keeps it). The REPL (`--no-shared`) does the same at `/exit` or Ctrl-D. The retention sweep catches those killed first: empty sessions over an hour old with no live owner, whatever `DAYS`/`MAX`, and skeleton-only dirs with no `*.json`. `session.keep_empty: true` (env `SAMAGOTCHI_SESSION_KEEP_EMPTY`) turns all of it off.
19
19
 
20
20
  **Attached by default:** plain `chi` and `chi --resume ID` run their session in a background worker and attach to it (`session.shared`, default `true`; env `SAMAGOTCHI_SESSION_SHARED`). The worker runs in the session's `working_directory`, so `!commands` and tools see the directory the session was started in. `session.shared: false` keeps the in-process REPL, `--no-shared` does for one run; a session the REPL has open can't be attached ("close it there first"). See [CLI: Sharing a session](cli.md#sharing-a-session).
21
21
 
@@ -25,7 +25,7 @@ A session is deleted if **expired by age OR overflow by count** (unless `keep_st
25
25
 
26
26
  **Deleting a session:** `chi sessions delete ID...`, `/exit --delete` in a terminal and the Web UI's delete all go through `SessionManager.delete_session`: it resolves a unique prefix, removes `<id>.json` and the whole `<id>/` dir, and returns what it removed. A session a plain REPL has open (`owner.lock` kind `tui`) is always refused. One a worker runs is refused unless the caller asks to stop it: then it stops the worker as `chi sessions stop` does and deletes once `owner.lock` is free (`--force` on the CLI, 10 s; the web always, 2 s; `/exit --delete` after the worker agreed to exit, 10 s). A worker that outlives the wait leaves the session in place. The web's route is `DELETE /api/sessions/:id` (200 `{status: "deleted", session_id, stopped}`; 409 `owned_by_tui` or `still_stopping`; 404).
27
27
 
28
- <a id="archiving-a-session"></a>**Archiving a session:** `chi sessions archive ID...`, `/archive` in a terminal and the Web UI's `archive` go through `SessionManager.archive_session`: it writes `<id>/archived` (`{"archived_at": …}`; not a session.json field, which every save rewrites) for the session and its delegated children. `Session.list` leaves archived sessions out unless `include_archived:`, and everything built on it follows: `chi sessions list` (`--archived` shows them, `[archived]`), the summaries (`--live`, json/tsv, the desktop panel, the agent's `list_sessions`) and the retention prune, which neither deletes nor counts them against `max_count`. `children_of` (max_children, `delegate_result`) still sees them, and `--resume`/`--attach ID`, `chi send`/`chi note ID` reach them as before. Refused: a turn running in the session or in one of its children (naming the child; 409 `busy` on the web), a plain REPL owning it (`owned_by_tui`), a `chi scratch` session (`scratch`). A live idle worker is stopped first, and the marker is written even if it is still shutting down. Input a human typed brings a session back (`{"unarchived_at": …}`): a prompt from the web, an attached or plain terminal or `chi send` (origin `web:*`, `tui:*`, `cli:send`, none), steering merged into a running turn, a `/continue` answer or a question answered (the step-limit question too); a delegate's follow-up, a plugin's turn, a reminder, the idle recap, a note and just opening it don't. A child's input brings only the child back. The retention sweep ages an unarchived session from when it was unarchived, so unarchiving an old session doesn't let the next sweep take it. `/archive` leaves the session first, as `/exit` does, then archives it: in an attached terminal the worker is asked to exit (one that stays up for another UI is stopped by the archive; a running turn refuses it), and an empty session is discarded instead; `chi scratch` refuses it. `chi sessions unarchive ID...` and the web's `unarchive` bring back the whole family. Web routes: `POST /api/sessions/:id/archive` (200 `{status, session_id, archived, stopped, discarded}`, ids) and `POST /api/sessions/:id/unarchive` (200 `{status, session_id, unarchived}`). The session hub and `GET /api/sessions` keep archived sessions, `archived: true` in their summaries: the page leaves them out of the strip, its count and the list at render, and "include archived" by the all-sessions search shows them, dimmed with an `archived` badge. The info bar marks a session whose live worker runs an older chi than the newest installed (`worker_version` in its summary): `chi 0.18.1 · Restart`, which asks to confirm and calls `POST /api/sessions/:id/restart` (a refusal's reason shows in a toast); a worker from before restarts shows `chi sessions stop ID` instead. The info bar reads `archive · stop · delete` (`unarchive` on an archived one); archiving the open session stays on it, with an Undo toast.
28
+ <a id="archiving-a-session"></a>**Archiving a session:** `chi sessions archive ID...`, `/archive` in a terminal and the Web UI's `archive` go through `SessionManager.archive_session`: it writes `<id>/archived` (`{"archived_at": …}`; not a session.json field, which every save rewrites) for the session and its delegated children. `Session.list` leaves archived sessions out unless `include_archived:`, and everything built on it follows: `chi sessions list` (`--archived` shows them, `[archived]`), the summaries (`--live`, json/tsv, the desktop panel, the agent's `list_sessions`) and the retention prune, which neither deletes nor counts them against `max_count`. `children_of` (max_children, `delegate_result`) still sees them, and `--resume`/`--attach ID`, `chi send`/`chi note ID` reach them as before. Refused: a turn running in the session or in one of its children (naming the child; 409 `busy` on the web), a prompt queued in `input/` (it would run on the next resume) or a live worker's step-limit question still open, in it or a child (409 `busy` too), a plain REPL owning it (`owned_by_tui`), a `chi scratch` session (`scratch`). A live idle worker is stopped first, and the marker is written even if it is still shutting down. Input a human typed brings a session back (`{"unarchived_at": …}`): a prompt from the web, an attached or plain terminal or `chi send` (origin `web:*`, `tui:*`, `cli:send`, none), steering merged into a running turn, a `/continue` answer or a question answered (the step-limit question too); a delegate's follow-up, a plugin's turn, a reminder, the idle recap, a note and just opening it don't. A child's input brings only the child back. The retention sweep ages an unarchived session from when it was unarchived, so unarchiving an old session doesn't let the next sweep take it. `/archive` leaves the session first, as `/exit` does, then archives it: in an attached terminal the worker is asked to exit (one that stays up for another UI is stopped by the archive; a running turn, a queued prompt or an open step-limit question refuses it), and an empty session is discarded instead; `chi scratch` refuses it. `chi sessions unarchive ID...` and the web's `unarchive` bring back the whole family. Web routes: `POST /api/sessions/:id/archive` (200 `{status, session_id, archived, stopped, discarded}`, ids) and `POST /api/sessions/:id/unarchive` (200 `{status, session_id, unarchived}`). The session hub and `GET /api/sessions` keep archived sessions, `archived: true` in their summaries: the page leaves them out of the strip, its count and the list at render, and "include archived" by the all-sessions search shows them, dimmed with an `archived` badge. The info bar marks a session whose live worker runs an older chi than the newest installed (`worker_version` in its summary): `chi 0.18.1 · Restart`, which asks to confirm and calls `POST /api/sessions/:id/restart` (a refusal's reason shows in a toast); a worker from before restarts shows `chi sessions stop ID` instead. The info bar reads `archive · stop · delete` (`unarchive` on an archived one); archiving the open session stays on it, with an Undo toast.
29
29
 
30
30
  **Lazy sweep:** automatic prune runs only while `chi web` runs, at most once per `session.sweep_interval_hours` (default 24): the web's session hub gives it a chance on its periodic full scan, and `GET /api/sessions` does too. No cron, and a terminal-only chi never sweeps. Manual prune is always available.
31
31
 
@@ -172,7 +172,7 @@ chi send --wait 3fa2 # sends nothing: waits fo
172
172
  - `--new` prints `<full id> started` on stdout. With `--wait` that line (and the `sent` line for an existing session) goes to stderr, so stdout is exactly the answer: the reply of the turn the message started, in full. It is the worker's `output/` file, the same one `delegate` reads; a turn that ends with only tool calls and no text has none.
173
173
  - `--wait` takes one session: `--new` or one id. A session with a running turn is refused (`busy: a turn is running; wait or attach`, exit 1): the message would run after it, and its reply would come back as the answer.
174
174
  - Exit codes with `--wait`: 0 answered; 3 the turn waits for an answer from you (a question, a hook's question, a guardrail approval, or the step-limit question: see [chi as a sub-agent](sub-agent.md#the-step-limit)), and the session keeps waiting: stderr gets the whole question (`waiting for an answer (approval): …`, its text, the numbered options, whether free text is allowed) and how to answer it (`chi answer ID --question QID --option N`, or `chi --attach ID`, the web); 4 `--timeout S` passed with the turn still running (it goes on; wait again); 1 the turn ended without a reply (canceled, failed or empty), the worker failed or vanished, or the session was stopped; 130 on Ctrl-C, which leaves the turn running (`still running: chi --attach ID`). There is no default timeout.
175
- - `--format json` (with `--wait`) prints one JSON object on stdout however the wait ends, for a script or a parent agent (see [chi as a sub-agent](sub-agent.md)): `{"status":"answered","session_id":…,"text":…}`; `{"status":"question",…,"question":{"id","kind","header","text","options","multi_select","allow_freeform","approval"},"answer_with":"chi answer ID --question QID --option N"}`, where `kind` is `question` (the model's), `hook` or `approval` (with the tool, command, cwd, rule, reason and offered scopes); `{"status":"running",…,"detail":…}` (exit 4, and on Ctrl-C); and `failed`, `canceled`, `no_answer`, `error`, `worker_gone` or `stopped` with a `detail` line (exit 1). A failure before the wait (no such session, a busy one) is `error` too. A usage error (exit 2) prints no JSON.
175
+ - `--format json` (with `--wait`) prints one JSON object on stdout however the wait ends, for a script or a parent agent (see [chi as a sub-agent](sub-agent.md)): `{"status":"answered","session_id":…,"text":…}`; `{"status":"question",…,"question":{"id","kind","header","text","options","multi_select","allow_freeform","approval"},"answer_with":"chi answer ID --question QID --option N"}`, where `kind` is `question` (the model's), `hook` or `approval` (with the tool, command, cwd, rule, reason and offered scopes); `{"status":"running",…,"detail":…}` (exit 4, and on Ctrl-C); and `failed`, `canceled`, `no_answer`, `error`, `worker_gone` or `stopped` with a `detail` line (exit 1); `failed` adds `error_kind` and `retryable`, `canceled` adds `cancel_reason` and `stopped_by`, when known. A failure before the wait (no such session, a busy one) is `error` too. A usage error (exit 2) prints no JSON.
176
176
  - `--wait ID` with no message (no `-m`, nothing piped) sends nothing: it waits for the session's next reply and prints and exits as above. A running session is fine (that is the point), so after exit 3, 4 or 130 an agent waits again this way while the user answers in the web or `chi --attach`; the question pending when it starts doesn't count again. An idle session with no worker waits until something wakes one. With `--new`, or two ids, it is a usage error (exit 2).
177
177
  - The 16 KiB cap applies: `git diff | chi send --new …` on a big diff is refused; name the branch in the message instead and let the session read it.
178
178
  - A session nobody attaches to stalls at its first question or guardrail ask until someone answers it (`chi answer`, the web, `chi --attach`), and its worker stays up meanwhile: it doesn't idle-exit while a question waits (`chi sessions list` shows it as `waiting`; `chi sessions stop ID` frees it). Otherwise it idle-exits after `session.idle_exit_minutes` like any worker. These are ordinary sessions: delete them like any other.
data/docs/sub-agent.md CHANGED
@@ -37,7 +37,7 @@ Exit codes, both commands:
37
37
  | 0 | `not_continued` | (`chi answer`) a Stop answered the step-limit question; the turn's work so far stays |
38
38
  | 3 | `question` | chi waits for an answer: `question`, `answer_with` |
39
39
  | 4 | `running` | `--timeout` passed; the turn goes on, wait again |
40
- | 1 | `failed`, `canceled`, `limit`, `no_answer`, `error`, `worker_gone`, `stopped` | `detail` says what happened (`limit`: the turn ran out of steps and nobody can be asked, e.g. an older worker; `chi send ID -m '/continue yes'` continues it) |
40
+ | 1 | `failed`, `canceled`, `limit`, `no_answer`, `error`, `worker_gone`, `stopped` | `detail` says what happened (`limit`: the turn ran out of steps and nobody can be asked, e.g. an older worker; `chi send ID -m '/continue yes'` continues it). `failed` adds `error_kind` (the provider error's kind: `credits`, `credits_held`, `rate_limited`, `server`, `auth`, `connection`, …) and `retryable` when known; `canceled` adds `cancel_reason` (`user`: a Stop in the web or `chi sessions stop`; `hook`; `ctrl_c`; `manual`) and `stopped_by` (the hook that stopped it, e.g. `loop-guard`) when known |
41
41
  | 2 | (no JSON) | usage error, or an option the question doesn't offer |
42
42
  | 130 | `running` | Ctrl-C; the turn goes on |
43
43
 
@@ -59,8 +59,11 @@ A turn that runs out of steps (`turn.max_iterations`, default 100) before it
59
59
  answers waits as a question of kind `continue` (`"limit": 100`, options
60
60
  `Continue` and `Stop`). `answer_with` continues it:
61
61
  `chi answer ID --question QID --option Continue`, and the continued turn's
62
- reply (or its next question) comes back on that same wait. To stop it:
63
- `--option Stop`, with `--text "why"` for the model (Continue takes no text);
62
+ reply (or its next question) comes back on that same wait. With
63
+ `--text "…"` the text joins the continued turn as a steer (as a `chi send -m`
64
+ into a running turn does, marked as coming from the parent agent), so one
65
+ answer continues and redirects it. To stop it:
66
+ `--option Stop`, with `--text "why"` for the model;
64
67
  that wait ends with `status: not_continued`, exit 0, and the turn's work so far
65
68
  stays in the session. A message instead (`chi send ID -m "…"`) drops the
66
69
  question and starts a new turn. It can't be dismissed.
@@ -132,7 +135,9 @@ answers from any local process.
132
135
  - `chi send --wait ID` with no message waits past a question already reported
133
136
  (the step-limit question too), until someone answers it.
134
137
  - An answer to a question that is no longer open (you answered it in the web
135
- first) isn't sent; `chi answer` says so and waits for what comes next.
138
+ first) isn't sent; `chi answer` says so and waits for what comes next. Its
139
+ JSON then has `"answered_here": false` (absent when the answer went in), so
140
+ an `answered` status there is someone else's answer's turn, not yours.
136
141
  - `chi send --wait` and `chi answer` don't read stdin unless it's a pipe or a
137
142
  file. Agent shells usually pass `/dev/null`, which is fine.
138
143
 
@@ -165,7 +170,8 @@ Start a task: `chi send --new --wait --format json --timeout 500 -m "<task>"`
165
170
  - `status: running` (exit 4): still working. Wait again:
166
171
  `chi send --wait --format json --timeout 500 <session_id>`.
167
172
  - `question.kind: continue`: chi ran out of steps before it answered. If it is
168
- getting somewhere, continue it (`answer_with`, `--option Continue`); if not,
173
+ getting somewhere, continue it (`answer_with`, `--option Continue`, with
174
+ `--text "…"` to steer it on the way); if not,
169
175
  stop it (`--option Stop --text "why"`, exit 0, status `not_continued`) or send
170
176
  a narrower follow-up instead. Ask your user when unsure.
171
177
  - Anything else (exit 1): report `detail` to your user. `chi --attach <session_id>`
@@ -34,7 +34,8 @@ module Samagotchi
34
34
  with it), then waits for what comes next and prints it as chi send
35
35
  --wait does.
36
36
  --question QID the question's id (chi send --wait prints it); a
37
- question no longer open is not answered
37
+ question no longer open is not answered (the JSON
38
+ says "answered_here": false)
38
39
  --option N|LABEL
39
40
  an option, by number (1-based) or label; repeat
40
41
  on a multi-select question
@@ -47,7 +48,8 @@ module Samagotchi
47
48
  An approval (a guardrail's ask) can be denied here: deny it, and
48
49
  tell your user; allowing it is the user's. With
49
50
  guardrails.parent_approvals: once, "Allow once" is let through.
50
- A step-limit question (kind continue): --option Continue, or
51
+ A step-limit question (kind continue): --option Continue [--text
52
+ STEER] (the text joins the continued turn as a steer), or
51
53
  --option Stop [--text WHY] (exit 0, status not_continued); it
52
54
  can't be dismissed.
53
55
  Exit: 0 answered, 1 failed, refused or the worker is gone, 2
@@ -214,9 +216,11 @@ module Samagotchi
214
216
  end
215
217
 
216
218
  # The question was answered elsewhere (the web first), or another one
217
- # waits now: said, then what comes next is waited for.
219
+ # waits now: said, then what comes next is waited for (the JSON says
220
+ # answered_here: false).
218
221
  # @return [nil]
219
222
  def not_open(qid)
223
+ @answered_here = false
220
224
  error_line("#{command_name}: question #{qid} is no longer open; not answered here, waiting for what comes next")
221
225
  nil
222
226
  end
@@ -39,6 +39,8 @@ module Samagotchi
39
39
  class SSEWriter
40
40
  DEFAULT_MAX_QUEUE = 1024
41
41
  DEFAULT_HEARTBEAT_INTERVAL = 15.0
42
+ # Queued by the hang-up watcher (#watch_hangup) to wake #drain!.
43
+ HANGUP = Object.new.freeze
42
44
 
43
45
  # @param engine [Samagotchi::Engine] the owning engine (live fan-out)
44
46
  # @param ring [Samagotchi::Bridge::RingBuffer] shared capture buffer
@@ -77,6 +79,7 @@ module Samagotchi
77
79
  @foreign_cursor = !cursor_epoch.nil? && cursor_epoch != @epoch
78
80
  @high_water = @foreign_cursor ? 0 : cursor_seq
79
81
  @handle = nil
82
+ @hung_up = false
80
83
  end
81
84
 
82
85
  # Non-blocking enqueue used as the per-connection live observer. Any
@@ -97,6 +100,7 @@ module Samagotchi
97
100
  # the client disconnects or the bridge stops.
98
101
  def serve!(io)
99
102
  write_sse_headers(io)
103
+ watcher = watch_hangup(io)
100
104
  begin
101
105
  if @join_with_snapshot
102
106
  # Subscribe and snapshot as one step of the event log, then go live.
@@ -119,11 +123,36 @@ module Samagotchi
119
123
  # Client hung up; nothing to do.
120
124
  ensure
121
125
  @engine.unsubscribe(handle: @handle) if @handle
126
+ watcher&.kill
122
127
  end
123
128
  end
124
129
 
125
130
  private
126
131
 
132
+ # A client that hangs up is noticed now, not at the next write (a
133
+ # heartbeat, up to its interval away): the Bridge stops counting its
134
+ # stream (Bridge#open_streams_except), so an attached /exit from the
135
+ # TUI attached after it isn't held by it. A thread waits for the
136
+ # socket to read EOF and wakes #drain!. A client sends nothing after
137
+ # its request; bytes it does send are dropped. Real sockets only.
138
+ def watch_hangup(io)
139
+ return nil unless io.is_a?(IO)
140
+
141
+ Thread.new do
142
+ loop do
143
+ case io.read_nonblock(4096, exception: false)
144
+ when nil then break
145
+ when :wait_readable then io.wait_readable
146
+ end
147
+ end
148
+ rescue IOError, SystemCallError
149
+ nil
150
+ ensure
151
+ @hung_up = true
152
+ @queue.push(HANGUP)
153
+ end.tap { |thread| thread.report_on_exception = false }
154
+ end
155
+
127
156
  def replay!(io, snapshot_seq)
128
157
  from_seq = @high_water
129
158
  oldest = @ring.oldest_seq
@@ -146,6 +175,9 @@ module Samagotchi
146
175
  def drain!(io)
147
176
  loop do
148
177
  payload = @queue.pop(@heartbeat_interval)
178
+ # The watcher's flag, not only its HANGUP: an overflow can drop that.
179
+ break if @hung_up
180
+
149
181
  if payload
150
182
  if payload.is_a?(Hash) && payload[:sse_reset]
151
183
  emit_reset(io)
@@ -38,8 +38,8 @@ module Samagotchi
38
38
  end
39
39
 
40
40
  # @return [Hash, nil] a copy of the turn in progress: prompt, origin,
41
- # continue, ordered parts (thinking / text / tool / input / steer /
42
- # reminder / notice), pending_question and the last event_seq folded
41
+ # continue, ordered parts (generation / thinking / text / tool /
42
+ # input / steer / reminder / notice), pending_question and the last event_seq folded
43
43
  # in. A finished tool part keeps the live row's action and duration_ms
44
44
  # too, a running one the time it has run so far (elapsed_ms), so a
45
45
  # join times its row from its start. A notice part holds the event
@@ -101,7 +101,9 @@ module Samagotchi
101
101
  # The turn in progress (#current_turn's shape, symbol keys) as the live
102
102
  # events that would have drawn it, in order, as turn_events.js
103
103
  # snapshotEvents replays it for the web (spec/shared/turn_snapshot.json
104
- # pins both): a generation_chunk per thinking or text part (a
104
+ # pins both): a generation_started per generation part (a join during
105
+ # a hold shows the step the model is on), a generation_chunk per
106
+ # thinking or text part (a
105
107
  # generation_completed closes text before the next step's), a tool's
106
108
  # tool_call_started and, once finished, its tool_call_completed (with
107
109
  # the live activity: action, tool, params, status), a merged_input
@@ -123,6 +125,9 @@ module Samagotchi
123
125
  end
124
126
  Array(turn[:parts]).each do |part|
125
127
  case part[:kind]
128
+ when "generation"
129
+ close_text.call
130
+ events << { type: :generation_started, iteration: part[:iteration] }
126
131
  when "thinking" then events << { type: :generation_chunk, text: "", thinking: part[:text], iteration: part[:iteration] }
127
132
  when "text"
128
133
  close_text.call if !text_iteration.nil? && text_iteration != part[:iteration]
@@ -233,6 +238,8 @@ module Samagotchi
233
238
  def fold_turn_event(event)
234
239
  parts = @turn[:parts]
235
240
  case event[:type]
241
+ when :generation_started
242
+ parts << { kind: "generation", iteration: event[:iteration] }
236
243
  when :generation_chunk
237
244
  if event.key?(:text)
238
245
  # Profile-split stream: thinking and visible text separately.
@@ -134,7 +134,8 @@ module Samagotchi
134
134
 
135
135
  # Streams held by anyone but +client_id+: a client's own stream (or two,
136
136
  # while it reconnects and the old one isn't noticed dead yet) doesn't
137
- # count. A closed stream counts until its next write fails (heartbeat).
137
+ # count. A closed stream stops counting once its writer reads the
138
+ # hang-up (SSEWriter#watch_hangup).
138
139
  # @return [Integer]
139
140
  def open_streams_except(client_id)
140
141
  @mutex.synchronize { @open_streams - (client_id ? @streams_by_client[client_id] : 0) }
@@ -698,7 +699,10 @@ module Samagotchi
698
699
  # event log held, so no POST /turn lands between its check and its
699
700
  # answer; it leaves from its loop after this reply.
700
701
  # 200 {status: "exiting", discard?: the session is empty and goes} ({status: "restarting"} for a restart), or 409
701
- # {status: "held", reason:} naming what keeps it up. Returns [headers, status, body].
702
+ # {status: "held", reason:} naming what keeps it up. A past +deadline+
703
+ # (see #handle_post_turn), checked with the event log held: 408
704
+ # deadline_passed, the worker not asked (its client said it didn't stop).
705
+ # Returns [headers, status, body].
702
706
  def handle_exit_request(session_id, body)
703
707
  return [{}, 501, { error: "exit_unavailable" }] unless @on_exit_request
704
708
 
@@ -709,7 +713,15 @@ module Samagotchi
709
713
  delete = fetched(parsed, "delete") == true
710
714
  restart = fetched(parsed, "restart") == true
711
715
  options = restart ? { delete: delete, restart: true } : { delete: delete }
712
- reason = @engine.synchronize_events { @on_exit_request.call(client_id, **options) }
716
+ deadline = fetched(parsed, "deadline")
717
+ return BAD_DEADLINE unless deadline_valid?(deadline)
718
+
719
+ reason = @engine.synchronize_events do
720
+ next :expired if expired?("exit_expired", deadline, sid: session_id, client_id: client_id)
721
+
722
+ @on_exit_request.call(client_id, **options)
723
+ end
724
+ return deadline_passed("exit request") if reason == :expired
713
725
  if reason.nil?
714
726
  return [{}, 200, { status: "restarting", session_id: @session_id }] if restart
715
727
 
@@ -849,7 +861,7 @@ module Samagotchi
849
861
  end
850
862
 
851
863
  # The 408 for a request read after its deadline: +what+ (turn, command,
852
- # answer, dismissal) was dropped.
864
+ # answer, dismissal, exit request) was dropped.
853
865
  def deadline_passed(what)
854
866
  [{}, 408, { error: "deadline_passed", detail: "the #{what} arrived after its client stopped waiting; not run" }]
855
867
  end
@@ -20,6 +20,10 @@ module Samagotchi
20
20
  # A reply's status line; its code is group 1.
21
21
  STATUS_LINE = %r{\AHTTP/1\.[01] (\d{3})}
22
22
  STREAM_CONNECT_ATTEMPTS = 3
23
+ # Seconds #stream waits for the Bridge's response headers, per attempt:
24
+ # a worker that took the connection and froze must not hold the web's
25
+ # proxy thread (as EventStream::HEADER_TIMEOUT for the TUI's).
26
+ STREAM_HEADER_TIMEOUT = 5.0
23
27
  # Seconds #stream waits for bytes before it asks `running` again.
24
28
  STREAM_POLL = 0.5
25
29
  # Seconds a one-shot request waits for the whole reply. Every route
@@ -28,7 +32,7 @@ module Samagotchi
28
32
  # raises Errno::ECONNREFUSED. The event stream has no such limit.
29
33
  READ_TIMEOUT = 30
30
34
  # Share of the read timeout a request's deadline allows (25 s of 30): the
31
- # Bridge drops a turn, command, answer or dismissal it reads after the
35
+ # Bridge drops a turn, command, answer, dismissal or exit it reads after the
32
36
  # deadline, so one this client timed out on, and said did not go
33
37
  # through, never runs when a frozen worker wakes. The rest covers the
34
38
  # write and the reply.
@@ -159,13 +163,14 @@ module Samagotchi
159
163
  # POST /session/:id/exit: ask the worker to exit now (+restart+: hand
160
164
  # the session to a new worker, a worker with the "restart" feature). 200
161
165
  # = it will (status "exiting"/"restarting"), 409 = something keeps it up (reason), 404 with
162
- # error "not_found" = a worker older than the route.
166
+ # error "not_found" = a worker older than the route, 408 = too late.
163
167
  # @param client_id [String] the asking UI, whose own streams don't hold
164
168
  # @return [Response]
165
169
  def request_exit(client_id:, delete: false, restart: false)
166
170
  body = { client_id: client_id }
167
171
  body[:delete] = true if delete
168
172
  body[:restart] = true if restart
173
+ body[:deadline] = deadline
169
174
  post("exit", body, read_body: true)
170
175
  end
171
176
 
@@ -258,7 +263,8 @@ module Samagotchi
258
263
  # @param running [#call, nil] asked before every read, and every
259
264
  # STREAM_POLL seconds of silence: the stream ends once it returns
260
265
  # false. A quiet stream is never cut otherwise.
261
- def stream(query: "", last_event_id: nil, running: nil)
266
+ # @param header_timeout [Float] see STREAM_HEADER_TIMEOUT
267
+ def stream(query: "", last_event_id: nil, running: nil, header_timeout: STREAM_HEADER_TIMEOUT)
262
268
  sock = nil
263
269
  # Absorb the probe→connect race around a resumed worker's bridge:
264
270
  # the sidecar probe can succeed a moment before the worker dies (or
@@ -266,7 +272,7 @@ module Samagotchi
266
272
  # avoid the silent empty-200 that EventSource would otherwise keep
267
273
  # re-opening.
268
274
  STREAM_CONNECT_ATTEMPTS.times do |attempt|
269
- sock, = connect_stream(query: query, last_event_id: last_event_id)
275
+ sock, = connect_stream(query: query, last_event_id: last_event_id, timeout: header_timeout)
270
276
  break if sock
271
277
 
272
278
  sleep(0.15 * (attempt + 1))
@@ -302,7 +308,7 @@ module Samagotchi
302
308
  end
303
309
 
304
310
  # Open GET /session/:id/stream and read past the response headers.
305
- # @param timeout [Float, nil] give up when the headers don't start in time
311
+ # @param timeout [Float, nil] give up when the headers aren't all in by then
306
312
  # @return [Array(TCPSocket, Integer), nil] the socket positioned at the
307
313
  # body and the HTTP status, or nil when the Bridge can't be reached
308
314
  def connect_stream(query: "", last_event_id: nil, timeout: nil)
@@ -310,10 +316,9 @@ module Samagotchi
310
316
  lei = last_event_id.to_s.strip
311
317
  last_event_line = lei.empty? ? "" : "Last-Event-ID: #{lei}\r\n"
312
318
  sock.write("GET /session/#{@session_id}/stream#{query} HTTP/1.1\r\nHost: #{@host}:#{@port}\r\nAccept: text/event-stream\r\n#{last_event_line}Connection: keep-alive\r\n\r\n")
313
- raise Errno::ETIMEDOUT if timeout && !sock.wait_readable(timeout)
314
-
315
- status = sock.gets.to_s[STATUS_LINE, 1].to_i
316
- while (line = sock.gets)
319
+ deadline = timeout && (Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout)
320
+ status = header_line(sock, deadline).to_s[STATUS_LINE, 1].to_i
321
+ while (line = header_line(sock, deadline))
317
322
  break if line.strip.empty?
318
323
  end
319
324
  [sock, status]
@@ -324,6 +329,25 @@ module Samagotchi
324
329
 
325
330
  private
326
331
 
332
+ # One line of the response headers, or nil at EOF; raises
333
+ # Errno::ETIMEDOUT once +deadline+ (monotonic, nil: none) has passed. A
334
+ # line read in pieces, so a Bridge that stops mid-line can't hold it.
335
+ def header_line(sock, deadline)
336
+ return sock.gets unless deadline
337
+
338
+ line = +""
339
+ loop do
340
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
341
+ raise Errno::ETIMEDOUT unless remaining.positive? && sock.wait_readable(remaining)
342
+
343
+ char = sock.getc
344
+ return line.empty? ? nil : line if char.nil?
345
+
346
+ line << char
347
+ return line if char == "\n"
348
+ end
349
+ end
350
+
327
351
  # When this client stops waiting for a reply, as epoch seconds (see
328
352
  # DEADLINE_SHARE). Wall clock: the worker runs on this machine and reads
329
353
  # the same one.