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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +77 -1
- data/docs/architecture.md +5 -3
- data/docs/cli.md +2 -1
- data/docs/configuration.md +11 -0
- data/docs/plugins.md +22 -8
- data/docs/sessions.md +3 -3
- data/docs/sub-agent.md +11 -5
- data/lib/samagotchi/answer_command.rb +7 -3
- data/lib/samagotchi/bridge/sse_writer.rb +32 -0
- data/lib/samagotchi/bridge/turn_accumulator.rb +10 -3
- data/lib/samagotchi/bridge.rb +16 -4
- data/lib/samagotchi/bridge_client.rb +33 -9
- data/lib/samagotchi/bundle_command.rb +6 -0
- data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
- data/lib/samagotchi/bundles/loop-guard/plugin.rb +61 -5
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/cli/parent_wait.rb +9 -2
- data/lib/samagotchi/continue_offer.rb +34 -9
- data/lib/samagotchi/engine.rb +47 -4
- data/lib/samagotchi/events.rb +1 -1
- data/lib/samagotchi/llm/chat_loop.rb +1 -1
- data/lib/samagotchi/llm/errors.rb +31 -0
- data/lib/samagotchi/llm/openai_chat.rb +33 -7
- data/lib/samagotchi/llm/prompt_cache.rb +83 -0
- data/lib/samagotchi/log_subscriber.rb +4 -2
- data/lib/samagotchi/parent_report.rb +24 -7
- data/lib/samagotchi/question_desk.rb +1 -7
- data/lib/samagotchi/reply_wait.rb +12 -2
- data/lib/samagotchi/session_manager.rb +26 -9
- data/lib/samagotchi/session_metrics.rb +6 -4
- data/lib/samagotchi/steer.rb +1 -1
- data/lib/samagotchi/terminal_ui/attached_loop.rb +18 -9
- data/lib/samagotchi/terminal_ui/event_renderer.rb +1 -1
- data/lib/samagotchi/terminal_ui/formatting.rb +7 -2
- data/lib/samagotchi/terminal_ui/live_region.rb +41 -2
- data/lib/samagotchi/terminal_ui/reline_seam.rb +10 -0
- data/lib/samagotchi/turn_state.rb +22 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/public/app.js +34 -7
- data/lib/samagotchi/web/public/index.html +7 -0
- data/lib/samagotchi/web/public/notifications.js +8 -3
- data/lib/samagotchi/web/public/notify.js +49 -7
- data/lib/samagotchi/web/public/timing.js +5 -3
- data/lib/samagotchi/web/public/turn_events.js +16 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9019626f95da411558ec5350303433de58fd07f9db6f9e22323856e4e2ed31a0
|
|
4
|
+
data.tar.gz: 015df1a469fb64ff5556ce6f683ee23abf039865a34584fff07e3f3c32e4724e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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
|
|
395
|
-
|
|
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.
|
data/docs/configuration.md
CHANGED
|
@@ -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
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
63
|
-
`--
|
|
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
|
|
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
|
|
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 /
|
|
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
|
|
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.
|
data/lib/samagotchi/bridge.rb
CHANGED
|
@@ -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
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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.
|