samagotchi 0.101.0 → 0.103.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 +67 -1
- data/bin/chi +31 -196
- data/docs/architecture.md +62 -6
- data/docs/cli.md +154 -7
- data/docs/configuration.md +32 -5
- data/docs/guardrails.md +2 -1
- data/docs/internals/bundles.md +1 -1
- data/docs/internals/engine.md +1 -1
- data/docs/internals/llm-context-forget.md +25 -1
- data/docs/internals/prompt-caching.md +8 -1
- data/docs/internals/remote-sessions.md +303 -0
- data/docs/internals/working-set.md +174 -15
- data/docs/plugins.md +32 -5
- data/docs/sessions.md +38 -14
- data/docs/sub-agent.md +8 -0
- data/docs/testing.md +7 -0
- data/lib/samagotchi/answer_command.rb +27 -25
- data/lib/samagotchi/bridge/turn_accumulator.rb +16 -0
- data/lib/samagotchi/bridge.rb +33 -7
- data/lib/samagotchi/bridge_client/event_stream.rb +81 -12
- data/lib/samagotchi/bridge_client/stream_request.rb +77 -0
- data/lib/samagotchi/bridge_client.rb +22 -38
- data/lib/samagotchi/bundle_command/check.rb +5 -1
- data/lib/samagotchi/bundle_command/try/command.rb +6 -6
- data/lib/samagotchi/bundle_command/try/sandbox.rb +3 -3
- data/lib/samagotchi/bundle_command.rb +4 -4
- data/lib/samagotchi/bundles/claude-peer/manifest.yml +2 -2
- data/lib/samagotchi/bundles/claude-peer/scripts/pair +30 -1
- data/lib/samagotchi/bundles/codex-peer/manifest.yml +2 -2
- data/lib/samagotchi/bundles/codex-peer/scripts/pair +49 -4
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +7 -4
- data/lib/samagotchi/bundles/guardrails/manifest.yml +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/chi_invocation.rb +43 -0
- data/lib/samagotchi/cli/command.rb +3 -2
- data/lib/samagotchi/cli/flags.rb +5 -0
- data/lib/samagotchi/cli/parent_wait.rb +22 -13
- data/lib/samagotchi/commands/registry.rb +12 -1
- data/lib/samagotchi/config.rb +89 -53
- data/lib/samagotchi/context_command.rb +80 -73
- data/lib/samagotchi/engine.rb +138 -15
- data/lib/samagotchi/idle_recap.rb +7 -1
- data/lib/samagotchi/idle_scheduler.rb +16 -1
- data/lib/samagotchi/kernel_loop.rb +13 -1
- data/lib/samagotchi/launch_mode.rb +22 -0
- data/lib/samagotchi/llm_context_edit.rb +32 -10
- data/lib/samagotchi/llm_context_forget.rb +24 -8
- data/lib/samagotchi/llm_context_view.rb +19 -7
- data/lib/samagotchi/memory_bundle/installer.rb +2 -0
- data/lib/samagotchi/memory_bundle/manifest.rb +13 -2
- data/lib/samagotchi/message_parts.rb +8 -3
- data/lib/samagotchi/note_command.rb +8 -7
- data/lib/samagotchi/parent_report.rb +55 -43
- data/lib/samagotchi/plugin/context.rb +2 -2
- data/lib/samagotchi/prompt.rb +1 -1
- data/lib/samagotchi/recap_command.rb +68 -0
- data/lib/samagotchi/recap_setup.rb +27 -14
- data/lib/samagotchi/remote_command.rb +170 -0
- data/lib/samagotchi/remotes.rb +130 -0
- data/lib/samagotchi/run_options.rb +233 -0
- data/lib/samagotchi/send_command/busy.rb +58 -0
- data/lib/samagotchi/send_command/parent_gate.rb +23 -10
- data/lib/samagotchi/send_command/wait_target.rb +16 -3
- data/lib/samagotchi/send_command.rb +186 -118
- data/lib/samagotchi/session.rb +48 -16
- data/lib/samagotchi/session_access/contract.rb +129 -0
- data/lib/samagotchi/session_access/dispatch.rb +133 -0
- data/lib/samagotchi/session_access/http_transport.rb +109 -0
- data/lib/samagotchi/session_access/local.rb +349 -0
- data/lib/samagotchi/session_access/remote.rb +638 -0
- data/lib/samagotchi/session_access/web_bridge_client.rb +181 -0
- data/lib/samagotchi/session_access.rb +141 -0
- data/lib/samagotchi/session_archive_command.rb +9 -5
- data/lib/samagotchi/session_commands.rb +53 -6
- data/lib/samagotchi/session_config.rb +411 -0
- data/lib/samagotchi/session_delete_command.rb +8 -4
- data/lib/samagotchi/session_manager.rb +7 -2
- data/lib/samagotchi/session_setup.rb +15 -10
- data/lib/samagotchi/sessions_command.rb +42 -16
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +54 -18
- data/lib/samagotchi/terminal_ui/attach_link.rb +104 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +82 -76
- data/lib/samagotchi/terminal_ui/formatting.rb +10 -3
- data/lib/samagotchi/terminal_ui/local_link.rb +130 -0
- data/lib/samagotchi/terminal_ui/version_lines.rb +5 -3
- data/lib/samagotchi/terminal_ui.rb +8 -6
- data/lib/samagotchi/testing/bundle.rb +3 -2
- data/lib/samagotchi/title_command.rb +9 -11
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +387 -79
- data/lib/samagotchi/web/public/app.js +2 -0
- data/lib/samagotchi/web/public/commands.js +8 -2
- data/lib/samagotchi/web/public/data.js +6 -4
- data/lib/samagotchi/web/public/index.html +37 -3
- data/lib/samagotchi/web/public/stage_frame.js +7 -2
- data/lib/samagotchi/web/public/stage_view.js +3 -2
- data/lib/samagotchi/web/public/stream_handlers.js +7 -1
- data/lib/samagotchi/web/public/turn_html.js +5 -0
- data/lib/samagotchi/web/public/working_set.js +247 -24
- data/lib/samagotchi/web/public/working_set_rail.js +39 -12
- data/lib/samagotchi/web/public/working_set_view.js +198 -25
- data/lib/samagotchi/web/session_access_routes.rb +343 -0
- data/lib/samagotchi/worker.rb +9 -6
- data/lib/samagotchi/worker_sidecar.rb +12 -5
- data/lib/samagotchi/working_set.rb +132 -20
- data/lib/samagotchi/working_set_command.rb +222 -0
- data/lib/samagotchi/working_set_pin.rb +34 -0
- data/lib/samagotchi/working_set_steer.rb +414 -0
- metadata +23 -2
- data/lib/samagotchi/bundle_session_settings.rb +0 -251
data/docs/sessions.md
CHANGED
|
@@ -31,7 +31,7 @@ A session is deleted if **expired by age OR overflow by count** (unless `keep_st
|
|
|
31
31
|
|
|
32
32
|
**Stopping a worker:** `chi sessions stop ID` marks the session stopped (an empty `<id>/stopped` file, not a session.json field, which a worker saving at that moment would write over; a resume removes it), sends its worker TERM and waits (up to 10 s) until the worker has let go of `owner.lock`. A running turn is canceled over the Bridge first, with reason `session_stopped` (the turn's line ends "(session stopped)", its note says `cancelled (session-stopped)`, and a command it was running says "stopped as the session was stopped"; a bare signal to the worker makes it "stopped by a signal to chi"; an older worker takes the reason as `manual`); the TERM stops whatever still runs as a Stop with reason `signal` and saves it (a spawned worker traps TERM, HUP and INT: `WorkerSignals`; a turn not ended within 3 s, 0.5 s after a stop or a second signal, is cut and the session saved as it was), and a stopped worker still saves its kept turn, since the marker, not the saved status, says stopped. Any TERM, HUP or INT does the same to a worker outside a stop: it leaves (log `worker stop reason=signal`) and the next send wakes a new one, so a `chi --resume ID` right after spawns a fresh worker. That is also how to restart a worker still running an older chi, which the attached terminal reports when a command or a question dismiss gets a 404, and the web when an answer, a dismiss, a command or a cancel does (`501 not_supported`; `BridgeClient.stale_worker_message`). A command this chi has that the older worker doesn't (`/llm-context`, the web's `llm ctx` chip, against a 0.39.0 worker) says the same, not `not a session command` (`SessionCommands.builtin_name`); a typo keeps its `400 unknown_command`. The web's `POST /api/sessions/:id/stop` waits the same way, for 2 s. A command sent during a turn that waits for its end (`/model X`, `!cmd`; see *Typing during a turn* in [cli.md](cli.md)) and hasn't run when the worker leaves (a stop, a crash) is answered `command_ran {status: "dropped"}`, so no UI keeps showing it waiting.
|
|
33
33
|
|
|
34
|
-
**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).
|
|
34
|
+
**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 page 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?force=true` (the page sends `force`; without it a live worker's session is 409 `worker_running`, as the CLI without `--force`; 200 `{status: "deleted", session_id, stopped}`; 409 `owned_by_tui` or `still_stopping`; 404).
|
|
35
35
|
|
|
36
36
|
<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. Typed while a turn runs, `/archive` waits for the turn's end (an attached terminal for the commands queued behind it too) and then archives. `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 `continue → · archive · stop · delete` (`unarchive` on an archived one; no `continue →` on a link continued already, see *Session chains*); archiving the open session stays on it, with an Undo toast.
|
|
37
37
|
|
|
@@ -151,6 +151,20 @@ system message marked `kind: turn_note` (the UIs don't show it; they showed the
|
|
|
151
151
|
nothing). The user's last message is still unanswered.]`, or `[SYSTEM: the previous turn's answer was refused by
|
|
152
152
|
the provider (cyber). …]` when the provider refused it.
|
|
153
153
|
|
|
154
|
+
After you drop or undrop an item of the working set between turns (`/working-set drop KEY`, the web list's
|
|
155
|
+
`drop`; [cli.md](cli.md#the-working-set)), or pin or unpin one under a strategy with the forget layer, the next turn
|
|
156
|
+
starts with one line about it (`kind: working_set`,
|
|
157
|
+
escaped like a context note; the UIs don't show it): `[WORKING SET: since your last turn the user dropped
|
|
158
|
+
lib/cart.rb from your context: you are sent a stub instead of its outputs; read it again only if the task needs
|
|
159
|
+
it.]`. Every op since the last turn joins that one line, and an undrop before the next turn takes its drop off it.
|
|
160
|
+
An add (`/working-set add KEY`) asks the model in that line to load the item first (`the user added lib/tax.rb to
|
|
161
|
+
your context: before anything else, load it with read "/abs/lib/tax.rb"`); an unadd before the next turn takes it
|
|
162
|
+
off.
|
|
163
|
+
The drop itself is saved on the entries (`edits`, kind `drop`), so `--resume`, rollbacks and forks keep it. A pin is
|
|
164
|
+
the session file's `working_set_pins` (`[{"kind": "file", "key": "/abs/path"}]`, `[]` for none; kinds `file`,
|
|
165
|
+
`memory`, `url`), not an edit: a respawned worker reads it, a continue and a fork (`ctx.sessions.fork`) copy it as
|
|
166
|
+
they copy the toolkit, and a delegate child starts without. A rollback leaves it (it is not in the conversation).
|
|
167
|
+
|
|
154
168
|
When the context window fills past 40% (then 60%, 80%), the native loop leaves a similar line (`kind: context`):
|
|
155
169
|
`[CONTEXT: about 55% of the context window is in use (estimated; bucket=40plus). context moderate — prefer targeted
|
|
156
170
|
and range reads over full-file dumps]`, once per rise; see [context telemetry](internals/context-telemetry.md).
|
|
@@ -180,7 +194,7 @@ pbpaste | chi send 3fa2 # the clipboard is the messa
|
|
|
180
194
|
|
|
181
195
|
- With both stdin and `-m`, stdin is context: each line becomes a `> ` quote (as web annotations quote a selection), then a blank line, then the message. With only one, it goes in as is. A terminal on stdin is ignored. Both empty is a usage error; over 16 KiB is refused, never cut.
|
|
182
196
|
- It goes through the same path as the web composer (`SessionManager.deliver_turn`): the worker's Bridge when it is up, so every attached UI shows it as your message (client id `cli:send`, shown like any user message); the input file when the worker is on its way out. During a running turn it is merged into that turn at the next step, like a message typed then, and cuts nothing. `--cut` asks for a cut, as `/cut TEXT` in a terminal does: a generation that has streamed only thinking for `steer.cut_after` seconds (per host/model: `steer_cut_after`) is cut (now, or once its thinking passes that), and the step starts again with the message. To an idle session `--cut` sends normally. `--cut --wait` into a running turn is refused like any `--wait` there (`busy: a turn is running`). `--queue` (not with `--cut`, a usage error) keeps the message for after the running turn: it runs as a turn of its own then (as `/queue TEXT` does), and a plain message sent after it still goes in at the running turn's next step. To an idle session it runs at once (it still goes as a queue, so a turn another client starts first never takes it as a steer). A worker older than queueing would steer it instead, so it is refused (to an idle session too) (`refused: this session's worker predates queueing, …; restart it`, exit 1) and nothing is sent; `chi sessions restart ID` and send again.
|
|
183
|
-
- A session with no worker gets one started (like `--attach` or the web composer). A session open in a plain REPL (`--no-shared`) refuses it.
|
|
197
|
+
- A session with no worker gets one started (like `--attach` or the web composer). A session open in a plain REPL (`--no-shared`) refuses it. Bridges listen on 127.0.0.1: another machine's sessions are reached through its chi web, `chi --remote NAME send …` ([Remote sessions](cli.md#remote-sessions)).
|
|
184
198
|
- Fire and forget: it returns once the message is queued and never prints the answer; that shows in whatever is attached. A guardrail "ask" waits for a UI to answer it, so with nothing attached the turn stalls there until one attaches (`chi --attach ID`).
|
|
185
199
|
- `--image PATH` (repeatable, up to 20) sends images with the message, as the web composer's chips do. Each file is read once before anything is sent (bmp, tiff and heic converted to png, large ones downscaled, as for `@path`); a missing file or one that isn't an image stops the send (exit 1; with `--wait --format json`, an `error` line) and nothing is sent; more than 20 is a usage error (exit 2). Each session gets its own copy in `<session>/images/`. Text is still required (`-m` or stdin; context alone counts), also with `--wait`. The session's model must see images: one known not to (the host's model list says text-only, `vision: false` in `models:` or `hosts:`, a native llama.cpp without a vision model) is refused before anything is sent, with the reason (`<id> refused: gemma can't take images (…); send text only or switch the model (/model)`); the other sessions of the same send still get it, and the exit is 1. With `--new` nothing is started. When chi can't tell (a native host whose `/props` doesn't answer, a list that doesn't say), the message is sent as before and a text-only model fails the turn in the session. A running turn doesn't take images mid-turn: the message runs as the next turn, and the line says `(runs after the current turn)`. With `--new` the session starts idle with the message as its preview, then the message goes in as its first turn once its worker is up (`<id> started with 1 image`); if the worker doesn't come up in 5 s the session is kept, with its id on the `failed:` line.
|
|
186
200
|
- A session command runs as the command, as typed in a terminal: `chi send -m "/model qwen" 3fa2` switches the model (`sent as a session command`), and the model never sees the line. What counts is what the session's worker knows (the built-ins, `!cmd`, its bundles' commands); an unknown `/word` (`/usr/bin/env is missing`) goes in as a message. A UI-only command (`/stats`, `/exit`, `/recap`, …) is refused instead: the worker doesn't run it, so the line would reach the model as a prompt (`<id> refused: /stats is a terminal and web command; use chi sessions stats <id8>`; exit 1, and nothing is sent). With `--new` a line starting with `/` or `!` starts the session idle and goes to its worker once it is up, which decides. With `--wait` a command has no reply to wait for: exit 0 at once, `<id> sent as a session command: no reply to wait for …` on stderr (`{"status":"command",…,"detail":…}` with `--format json`); its output shows where the session is open.
|
|
@@ -211,9 +225,9 @@ chi send --new --continues last:3fa2 -m "new day" # the next link of 3fa2's
|
|
|
211
225
|
- 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.
|
|
212
226
|
- `--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`, `removed` (a `--queue --wait` message taken back before it ran), `no_answer`, `error`, `worker_gone` or `stopped` with a `detail` line (exit 1); `failed` adds `error_kind` and `retryable`, `canceled` adds `cancel_reason` (`worker_stopped`: the turn waited for died with its worker and the next worker settled it) 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.
|
|
213
227
|
- `--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. With a turn running, that turn's reply (its `interrupted_turn` mark names it), not a turn's queued behind it; with the step-limit question standing, the reply of the turn its Continue runs; with an idle session whose last turn already ended, that ended turn is the answer: its reply, or its end when it left no reply — a turn that finished in the gap between waits is reported, not waited for. With none of those (or a worker whose `bridge.json` lacks `turn_ids`), the next reply of any turn. 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).
|
|
214
|
-
- `--set
|
|
228
|
+
- `--set KEY=VALUE` (with `--new`, repeatable) gives the new session a setting of its own, as `chi --set` does (see *Session settings* below): a core key a session may set (`--set recap.enabled=false`) or a bundle's `BUNDLE.KEY` (`chi send --new --set cost-cap.cap=3 -m "…"` starts it with a $3 cap). A key no session sets (`guardrails.mode`), a bundle or key the installed bundles don't offer, or a value of another type than the key takes (`recap.enabled=maybe`, `cost-cap.cap=five`) is a usage error (exit 2), as is `--set` without `--new`.
|
|
215
229
|
- `--parent-answers LEVEL` (with `--new`) gives the new session its own `guardrails.parent_answers` (`hard`, `standard`, `soft`), tighten-only: config.yml's applies when it is stricter, with `chi send: warning: --parent-answers soft is looser than config.yml's …`. See *Parent answers* below.
|
|
216
|
-
- `--continues ID` (with `--new`) starts the next link of that session's chain instead (see *Session chains*): in its folder, on its model (or the one `--model` names), LLM context, thinking level, toolkit,
|
|
230
|
+
- `--continues ID` (with `--new`) starts the next link of that session's chain instead (see *Session chains*): in its folder, on its model (or the one `--model` names), LLM context, thinking level, toolkit, session settings and parent answers, so `--dir`, `--llm-context*`, `--thinking`, `--toolkit`, `--tool`, `--no-tool`, `--set` and `--parent-answers` are refused beside it; `ID` may be a prefix or `last:ID`, the chain's latest link. Without a message the new link starts idle; `--wait` then has nothing to wait for (a usage error). It prints `<full id> started (continues 3fa2c1d0)`, with `; 2 delegates moved` inside the parentheses when open delegates moved to it; a refusal (continued already, naming the next link; an open delegate that can't move; a turn running) is `chi send: refused: …`, exit 1, and a `--model` it can't start on is `chi send: could not start a session: …`, exit 1, nothing archived. A fallback to the default model (below) prints `chi send: warning: …`.
|
|
217
231
|
- 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.
|
|
218
232
|
- 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.
|
|
219
233
|
|
|
@@ -236,7 +250,7 @@ A session can **continue** an earlier one: "the next run of the same routine" (a
|
|
|
236
250
|
- **Its open delegates move to the new link** instead: each direct delegate that is running, waiting for an answer, has a live worker or a reply its parent wasn't given, or has such a delegate below it (checked again after the recap wait, so one delegated meanwhile moves too). It moves with its own delegates, stays unarchived, and from then on is the new link's: its card folds under the new link, its reports ring the new link (an idle one runs a wake turn for them, as for its own delegates), `delegate_result` and the `⑂` chip count it there, and what its parent was already told (`delegates.json`) and any report still waiting come along, so nothing is reported twice. A moved delegate gets a context note from `session chain`, `Your parent session is now 4b7e9a01 (…), the next link of its session chain: …`, as its system prompt keeps naming the parent it started with. The move writes `<child>/parent.json` (`{parent_id, from, at}`) beside the child's session file, never the file itself, so a live child worker saving its own copy changes nothing; every reader applies it (a later move overwrites it; it is never removed). A continue is **refused for an open delegate that can't move** (`SessionContinue::Refused`, naming it): one whose live worker runs an older chi (`older chi worker 0.46.1; restart it`: `chi sessions restart <id>`, or stop it) or that is open in a chi REPL (close it there). An older `chi web` or CLI still running shows a moved delegate under the archived link until it is restarted.
|
|
237
251
|
- **If the continue fails part-way:** a new link that fails to start gives its moved delegates back before it is deleted (each gets a note saying so; its worker is stopped first, if one started, and a worker that won't stop is reported, the link left), with any report one of them rang it with meanwhile; the previous link, back in the lists, is woken for reports waiting. A continue that dies in the middle leaves a `move.json` in the new link's folder: the new link's next worker finishes that move (only the delegates it names, and without a second note to one already told) as it starts. Until the new link's own worker holds it, a ring from a moved delegate doesn't start another (a `starting` marker in its folder, which that worker removes; a stale one, over 60 s old or from a process that is gone, is ignored). An open delegate below an archived delegate refuses the continue (`under archived …`): unarchive that one, or archive this one.
|
|
238
252
|
- **Refused too:** a link that is continued already (naming the link that continues it; continue that one, or `last:<id>`), a previous link whose folder is gone, and one whose model's host can't run it when no default model is configured to fall back to. A refusal starts nothing and archives nothing; a new link that fails to start puts back what was archived. Every refusal that can be known up front (the archive's ones included) comes before the recap wait. One continue of a session runs at a time (a lock in its folder; other chains don't wait), so a double click finds the first one's link and is refused, while a `last:<id>` continue that waited follows the chain to its new end.
|
|
239
|
-
- **Web:** `POST /api/sessions` takes `continues: "<id, prefix or last:id>"`, with `idle: true` (a new link waiting for its first message, previewed by the previous link's, or `preview`) or a `prompt`, and `model` (or `model_name`) for the new link's model, but not `dir`, `llm_context` or `
|
|
253
|
+
- **Web:** `POST /api/sessions` takes `continues: "<id, prefix or last:id>"`, with `idle: true` (a new link waiting for its first message, previewed by the previous link's, or `preview`) or a `prompt`, and `model` (or `model_name`) for the new link's model, but not `dir`, `llm_context`, `thinking`, `settings`, `toolkit`, `tools`, `no_tools` or `parent_answers` (400 `invalid_continues`: the previous link's are taken). 201 is the new session's summary, `continues` set, with `moved`: the ids of the delegates that moved to it (`[]` for none), and `model_warning`: the default-model fallback's warning, then any about the model id, joined by `; ` (`null` for none), and `opener`: the previous link's first typed message, in full, when that link continues one itself (`null` otherwise: a chain's first link's first message is its task); 404 `not_found`; 400 `ambiguous_id` for a prefix of several sessions, `invalid_model` for a given model whose host config.yml doesn't have (or a fallback with no default model); 409 `continued` with `next_id` (the link that continues it already, which the page opens instead), `open_children` with `ids` (open delegates that can't move), `folder_gone`, `busy`, `scratch` or `owned_by_tui`. Every session summary carries `continues`.
|
|
240
254
|
- **The page:** the info bar's `continue →` continues the open session (`continues: <id>, idle: true`; it reads `continuing…` through the recap wait) and opens the new link with an opener in the composer, not sent (a draft written in this session stays instead; one carried over from another session or the start page gives way to the opener, and the toast's **Restore draft** brings it back): the previous link's own first typed message when that link continues one (the 201's `opener`; a `!shell` line isn't taken), else `Continue where we left off.`. So whatever you sent on the last link comes back on the next one, and editing it once changes it for every later link; the previous link is archived, a toast says so (`Continued 3fa2c1d0 in 4b7e9a01; 3fa2c1d0 is archived; 1 open delegate moved`), and a moved delegate's card folds under the new link. When the previous link's model's host is gone the link starts on the default model and the toast carries the server's `model_warning` too, and then stays until closed. A fresh link (one with no turn yet, however it was made: `continue →`, `chi send --new --continues`, a reload) shows the model picker in its composer with the link's model, until its first turn starts (a failed or canceled first turn ends it too); a pick runs `/model <name>` in the link (a command bubble; the info bar follows) and changes neither the start page's remembered model nor its Recent list, and a Send right after it waits for the `/model` to be taken. A link a chi REPL owns, or one continued already, shows none. A link continued already has no `continue →`: its `→` link goes to the next link (a stale page's continue answered 409 `continued` opens `next_id`). A continue refused for open delegates that can't move shows a toast that names each one (its preview and what the server says to do: `older chi worker 0.46.1; restart it`, `open in a chi REPL; close it there`), each a link to its page; any other refusal shows the server's words. The info bar of a link carries `← 2026-10-08` (the previous link's day) and, on an earlier link, `2026-10-10 →`, each a link with its id, its full start (`2026-10-10 14:32`) and its preview in the tooltip; a neighbour that started on the open link's own day shows its time instead (`← 09:12`, `14:32 →`). A link made by `continue →` inherits the previous link's preview (and its title, if it has one) when it is created, so its card keeps its chain's title whatever opener it is sent, and its top bar and browser tab show its card's title. A link whose title is the opener and nothing else (a `chi send --new --continues` link whose first message is `Continue where we left off.`) shows its chain's first link's title instead, on its card, in the popover and in its info bar's title line, display only (its messages and data keep the opener; its own title when the first link is gone). The lists fold a chain into the card of its latest listed link, whatever the earlier links' archived state (typing in an archived link brings it back, folded there still), their delegates with them: the card's `↩ #N` chip (`#N of M` when later links aren't listed; its tooltip `Session chain: link N of M`) opens a popover of the other links, newest first (`#N`, its date, with its time (`2026-10-17 14:32`) when another link of the chain started the same day, its own title when it differs from the card's (a link inherits its chain's, so mostly it doesn't), else its recap's first sentence, else its preview, `archived`), a click opens one; the chip shows a live dot or `waiting` for a link that has a worker or waits on the user, and a waiting link puts its chain's card first. All sessions folds nothing while a search is typed or "include archived" is on: every link is a card of its own then, its chip `#N of M`, with its own delegates folded in, so a search finds an earlier link and its delegates.
|
|
241
255
|
- **CLI:** `chi send --new --continues (ID|PREFIX|last:ID) [--model M] [-m TEXT]` (see *Starting a session*).
|
|
242
256
|
- **Which link:** an id, a unique prefix, or `last:<id or prefix>`, the latest link of the chain that session is in. It walks the links that still exist: a deleted link in the middle (`chi sessions delete`, the retention sweep of an unarchived one) ends the walk before it, so `last:` of an earlier link then names that earlier link, and continuing it starts a second line beside the links after the gap; name a link after the gap instead. A trigger names the chain this way: `chi send` to an archived link would bring it back (a human's input), not continue the chain.
|
|
@@ -274,15 +288,25 @@ fork keep it, and a `delegate` child starts with its parent's as it is then (a l
|
|
|
274
288
|
it). A value that isn't a level reads as `hard`. `/parent-answers` changes it; loosening it is yours alone (see
|
|
275
289
|
[Guardrails](guardrails.md#answers-from-a-parent-agent)).
|
|
276
290
|
|
|
277
|
-
##
|
|
278
|
-
|
|
279
|
-
A session
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
291
|
+
## Session settings
|
|
292
|
+
|
|
293
|
+
A session's own settings are what `chi --set`, `chi send --new --set …` or `POST /api/sessions` `settings` gave it
|
|
294
|
+
at creation. One changes live: `/recap on|off|default` (any UI, or `chi send -m "/recap off" ID`) sets or removes
|
|
295
|
+
`recap.enabled` and the worker saves it, so a respawn keeps it. The file keeps them in two fields:
|
|
296
|
+
|
|
297
|
+
- `config_settings` (`{"recap.enabled": false}`): the core keys a session may set (`recap.enabled`,
|
|
298
|
+
`recap.sentences`; see [Per-session settings](configuration.md#per-session-settings)), typed by their key. They go
|
|
299
|
+
first, over the CLI, the environment and config.yml. A value the file holds for a key that is no longer settable, or
|
|
300
|
+
of another type, is dropped when the file is read.
|
|
301
|
+
- `bundle_settings` (`{"cost-cap": {"cap": 3}}`): a bundle's settings, which its plugin reads in `ctx.settings`, over
|
|
302
|
+
config.yml's `bundles:` ([Per-session settings](plugins.md#per-session-settings)).
|
|
303
|
+
|
|
304
|
+
Both are `{}` for none and for a session from before them. `POST /api/sessions` takes them flat, as `--set` names
|
|
305
|
+
them (`"settings": {"recap.enabled": false, "cost-cap.cap": 3}`), checked the same way (`400 invalid_settings`
|
|
306
|
+
otherwise; `settings` beside `continues` is refused). A respawned or woken worker reads the same, and a continue
|
|
307
|
+
(`continue →`, `chi send --new --continues`) and a fork (`ctx.sessions.fork`) copy them, as they copy the toolkit. A
|
|
308
|
+
delegate child starts without (a bundle has its own setting for children, such as cost-cap's `child:`), so its recap
|
|
309
|
+
follows config.yml. `/stats` names them on a `settings:` line, and `chi sessions stats --format json` as `settings`.
|
|
286
310
|
|
|
287
311
|
## Delegating
|
|
288
312
|
|
data/docs/sub-agent.md
CHANGED
|
@@ -164,6 +164,14 @@ An answer typed into `chi --attach` or the REPL counts as a parent's too when
|
|
|
164
164
|
stdin isn't a terminal or an agent marker (`CLAUDECODE`, `AI_AGENT`,
|
|
165
165
|
`CODEX_THREAD_ID`, `SAMAGOTCHI_PARENT_SESSION`) is set.
|
|
166
166
|
|
|
167
|
+
Through `chi web` (`POST /api/sessions/:id/answer`, what chi answer uses against
|
|
168
|
+
another machine's sessions) the answer keeps chi answer's mark: the route passes
|
|
169
|
+
on `client_id: "cli:answer"` and drops any other id, so a forwarded answer can
|
|
170
|
+
only be held to the parent rules, never pass for the user's own. The worker's
|
|
171
|
+
refusal (403 `parent_answer_refused`) comes back as it said it. A parent's
|
|
172
|
+
`/continue yes` and a loosening `/parent-answers` sent through `chi web`'s create
|
|
173
|
+
and turn routes are refused there too, held to that machine's config.yml.
|
|
174
|
+
|
|
167
175
|
The worker checks it again with its own config.yml. The guardrails settings
|
|
168
176
|
have no environment variables, and a worker drops any `SAMAGOTCHI_GUARDRAILS_*`
|
|
169
177
|
it inherits; but a parent that sets `XDG_CONFIG_HOME` picks which config.yml a
|
data/docs/testing.md
CHANGED
|
@@ -23,6 +23,13 @@ style pass: add a rule only for a bug class it would have caught. It runs in abo
|
|
|
23
23
|
`rake release:check`; `rake lint` stays RuboCop alone (CI's rubocop job has no node). A name that is also a browser
|
|
24
24
|
global (`name`, `status`, `open`…) passes even when its import is gone: give an export such a name only with care.
|
|
25
25
|
|
|
26
|
+
CI's e2e job keeps the tests, chi and the fake model on the runner but runs Chromium in Playwright's image
|
|
27
|
+
(`mcr.microsoft.com/playwright:v<version>-noble`, `playwright run-server` on the host network); the tests connect
|
|
28
|
+
to it with `PW_TEST_CONNECT_WS_ENDPOINT`, and traces and the report stay on the runner. Nothing is installed with
|
|
29
|
+
apt. The tag is the installed `@playwright/test`'s version, read at run time, so bumping it in `package-lock.json`
|
|
30
|
+
moves the image too (`spec/ci_playwright_image_spec.rb` keeps a pinned tag out of the workflow). To run the suite
|
|
31
|
+
that way locally: `npx playwright run-server --port 3333`, then `PW_TEST_CONNECT_WS_ENDPOINT=ws://127.0.0.1:3333/ npm run e2e`.
|
|
32
|
+
|
|
26
33
|
No spec reads your own setup. The suite points `XDG_CONFIG_HOME` and `XDG_STATE_HOME` at temp folders,
|
|
27
34
|
clears your `SAMAGOTCHI_*` environment, and keeps every spec off the network (WebMock).
|
|
28
35
|
|
|
@@ -10,6 +10,7 @@ require_relative "guardrails/parent_policy"
|
|
|
10
10
|
require_relative "cli/command"
|
|
11
11
|
require_relative "cli/flags"
|
|
12
12
|
require_relative "cli/parent_wait"
|
|
13
|
+
require_relative "session_access/local"
|
|
13
14
|
|
|
14
15
|
module Samagotchi
|
|
15
16
|
# `chi answer`: answer the question a session's worker waits on (exit 3
|
|
@@ -68,11 +69,14 @@ module Samagotchi
|
|
|
68
69
|
f.value "--format"
|
|
69
70
|
end
|
|
70
71
|
|
|
71
|
-
|
|
72
|
+
# @param access [SessionAccess::Local, nil] where the sessions are;
|
|
73
|
+
# default this machine's, in +state_dir+
|
|
74
|
+
def initialize(argv, stdout: $stdout, stderr: $stderr, state_dir: nil, access: nil)
|
|
72
75
|
@argv = argv.dup
|
|
73
76
|
@stdout = stdout
|
|
74
77
|
@stderr = stderr
|
|
75
78
|
@state_dir = state_dir || Session.default_state_dir
|
|
79
|
+
@access = access || SessionAccess::Local.new(state_dir: @state_dir)
|
|
76
80
|
end
|
|
77
81
|
|
|
78
82
|
private
|
|
@@ -106,23 +110,23 @@ module Samagotchi
|
|
|
106
110
|
def run_parsed(options)
|
|
107
111
|
id = resolve(options[:id]) or return 1
|
|
108
112
|
qid = options[:question].strip
|
|
109
|
-
session =
|
|
113
|
+
session = @access.show(id)
|
|
110
114
|
pending = session.pending_question
|
|
111
115
|
# Taken before the answer goes in, with the question answered as the
|
|
112
116
|
# one already reported: the file keeps it until the turn thread
|
|
113
|
-
# wakes, and it must not come back as exit 3.
|
|
114
|
-
|
|
115
|
-
baseline = ReplyWait.baseline_of(session, question_id: qid)
|
|
116
|
-
# The turn the answer is about: the one asking (running), or the one
|
|
117
|
+
# wakes, and it must not come back as exit 3. Its own_turn is the
|
|
118
|
+
# turn the answer is about: the one asking (running), or the one
|
|
117
119
|
# whose continue offer stands. Its own reply is the answer's, not a
|
|
118
120
|
# turn queued behind it; nil (an older worker): the next reply.
|
|
119
|
-
|
|
121
|
+
start = @access.prepare_wait(id, session: session, question_id: qid)
|
|
120
122
|
|
|
121
123
|
if pending && pending.id.to_s == qid
|
|
122
124
|
return failure(not_dismissable(pending)) if options[:dismiss] && ParentReport.continue?(pending)
|
|
123
125
|
|
|
124
126
|
selected = selection(pending, options) or return @selection_status
|
|
125
|
-
|
|
127
|
+
# Remotely the worker's check alone (chi web passes chi answer's
|
|
128
|
+
# client_id on, and its 403 says why).
|
|
129
|
+
refusal = parent_refusal(pending, selected, session) unless @access.remote?
|
|
126
130
|
return failure(refusal) if refusal
|
|
127
131
|
|
|
128
132
|
posted = post(id, qid, selected, options)
|
|
@@ -134,7 +138,7 @@ module Samagotchi
|
|
|
134
138
|
end
|
|
135
139
|
# Answered here, or no longer open (answered elsewhere, or a newer
|
|
136
140
|
# question): what comes next either way.
|
|
137
|
-
wait_for_reply(id, cursor: cursor, baseline: baseline, timeout: options[:timeout], turn:
|
|
141
|
+
wait_for_reply(id, cursor: start.cursor, baseline: start.baseline, timeout: options[:timeout], turn: start.own_turn)
|
|
138
142
|
end
|
|
139
143
|
|
|
140
144
|
# The labels to send. A number is an option's place (1-based); an
|
|
@@ -163,6 +167,8 @@ module Samagotchi
|
|
|
163
167
|
# with this process's config.yml tightened by the session file's own
|
|
164
168
|
# parent_answers, and the question's own tags). A deny, text alone or a
|
|
165
169
|
# dismissal always passes. The worker checks it again with its own.
|
|
170
|
+
# Machine-local (this machine's config.yml): remotely skipped here; the
|
|
171
|
+
# server forwards CLI_ANSWER and the worker's check refuses (P2).
|
|
166
172
|
def parent_refusal(pending, selected, session)
|
|
167
173
|
offered = Array(pending.options).map(&:to_s)
|
|
168
174
|
indices = selected.map { |label| offered.index(label) }
|
|
@@ -177,23 +183,18 @@ module Samagotchi
|
|
|
177
183
|
"(--option Stop --text WHY stops it)"
|
|
178
184
|
end
|
|
179
185
|
|
|
180
|
-
# Post the answer (or the dismissal) to the live worker
|
|
186
|
+
# Post the answer (or the dismissal) to the live worker (its Bridge,
|
|
187
|
+
# through SessionAccess), marked as chi answer's.
|
|
181
188
|
# @return [Integer, nil] an exit status when it didn't go in; nil to
|
|
182
189
|
# wait (it went in, or the question was no longer open: 409)
|
|
183
190
|
def post(id, qid, selected, options)
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
else
|
|
192
|
-
client.answer(id: qid, selected: selected, freeform: options[:text],
|
|
193
|
-
client_id: ClientId::CLI_ANSWER)
|
|
194
|
-
end
|
|
195
|
-
break unless reply.status == 408
|
|
196
|
-
end
|
|
191
|
+
reply = if options[:dismiss]
|
|
192
|
+
@access.dismiss(id, question_id: qid)
|
|
193
|
+
else
|
|
194
|
+
@access.answer(id, question_id: qid, selected: selected, freeform: options[:text],
|
|
195
|
+
client_id: ClientId::CLI_ANSWER)
|
|
196
|
+
end
|
|
197
|
+
return failure(worker_gone(id)) unless reply
|
|
197
198
|
|
|
198
199
|
case reply.status
|
|
199
200
|
when 200 then nil
|
|
@@ -209,7 +210,7 @@ module Samagotchi
|
|
|
209
210
|
return failure(worker_gone(id)) if reply.json&.dig("error") == "unknown_session"
|
|
210
211
|
|
|
211
212
|
cant = options[:dismiss] ? "dismiss a question" : "take an answer"
|
|
212
|
-
failure(BridgeClient.stale_worker_message(id, cant: cant))
|
|
213
|
+
failure(BridgeClient.stale_worker_message(id, cant: cant, via: @access.invocation))
|
|
213
214
|
else failure("the worker answered #{reply.status}: #{reply.json&.dig("detail") || reply.body}")
|
|
214
215
|
end
|
|
215
216
|
rescue Errno::ETIMEDOUT
|
|
@@ -229,7 +230,8 @@ module Samagotchi
|
|
|
229
230
|
end
|
|
230
231
|
|
|
231
232
|
def worker_gone(id)
|
|
232
|
-
"the worker is gone and the question with it; send the task again:
|
|
233
|
+
"the worker is gone and the question with it; send the task again: " \
|
|
234
|
+
"#{@access.invocation.command("send --wait -m \"…\" #{id}")}"
|
|
233
235
|
end
|
|
234
236
|
|
|
235
237
|
# @return [Integer] 1, after the line
|
|
@@ -35,6 +35,7 @@ module Samagotchi
|
|
|
35
35
|
@merged_origins = []
|
|
36
36
|
@recap = nil
|
|
37
37
|
@continue_offer = nil
|
|
38
|
+
@working_set = nil
|
|
38
39
|
end
|
|
39
40
|
|
|
40
41
|
def call(event)
|
|
@@ -192,6 +193,17 @@ module Samagotchi
|
|
|
192
193
|
end
|
|
193
194
|
private_class_method :with_images
|
|
194
195
|
|
|
196
|
+
# The running turn's working set: its last :working_set_updated
|
|
197
|
+
# ({items:, at:}, Engine#emit_working_set). The session's kept messages
|
|
198
|
+
# lag the running turn, so this is what a mid-turn read of the set
|
|
199
|
+
# answers (Web::App#handle_working_set).
|
|
200
|
+
# @return [Hash, nil] a copy; nil with no turn running, or before the
|
|
201
|
+
# turn emitted one (its set is then the session's: the Engine emits
|
|
202
|
+
# only a set that differs from the last one)
|
|
203
|
+
def working_set
|
|
204
|
+
@mutex.synchronize { @working_set && Marshal.load(Marshal.dump(@working_set)) }
|
|
205
|
+
end
|
|
206
|
+
|
|
195
207
|
# @return [String, nil] the last idle recap, until a turn makes it stale
|
|
196
208
|
def recap
|
|
197
209
|
@mutex.synchronize { @recap }
|
|
@@ -249,6 +261,7 @@ module Samagotchi
|
|
|
249
261
|
parts: [], pending_question: nil }
|
|
250
262
|
@turn[:resumed] = true if event[:resumed]
|
|
251
263
|
@tool_started_at = {}
|
|
264
|
+
@working_set = nil
|
|
252
265
|
@turn[:images] = event[:images] if event[:images]
|
|
253
266
|
@turn[:cut_after] = event[:cut_after] if event[:cut_after]
|
|
254
267
|
when :input_merged
|
|
@@ -261,6 +274,7 @@ module Samagotchi
|
|
|
261
274
|
if Events::TURN_END.include?(type)
|
|
262
275
|
@turn = nil
|
|
263
276
|
@merged_origins = []
|
|
277
|
+
@working_set = nil
|
|
264
278
|
elsif event[:event_seq]
|
|
265
279
|
@turn[:event_seq] = event[:event_seq]
|
|
266
280
|
end
|
|
@@ -321,6 +335,8 @@ module Samagotchi
|
|
|
321
335
|
Array(event[:steers]).each do |steer|
|
|
322
336
|
parts << { kind: "steer", iteration: event[:iteration], source: steer[:source].to_s, text: steer[:text].to_s.dup }
|
|
323
337
|
end
|
|
338
|
+
when :working_set_updated
|
|
339
|
+
@working_set = { items: event[:items], at: event[:at] }
|
|
324
340
|
when :reminder_injected
|
|
325
341
|
parts << { kind: "reminder", reminders: Array(event[:reminders]).map(&:dup) }
|
|
326
342
|
when :question_requested
|
data/lib/samagotchi/bridge.rb
CHANGED
|
@@ -172,6 +172,9 @@ module Samagotchi
|
|
|
172
172
|
# One a worker that died left is not open.
|
|
173
173
|
@pending_card.clear
|
|
174
174
|
@pending_card_handle = @engine.subscribe(observer: @pending_card)
|
|
175
|
+
# #stop and #close_intake wake the acceptor through it: closing the
|
|
176
|
+
# listen socket doesn't cut a select on it short on Linux.
|
|
177
|
+
@wake_reader, @wake_writer = IO.pipe
|
|
175
178
|
@accept_thread = Thread.new { accept_loop }
|
|
176
179
|
@accept_thread.report_on_exception = false
|
|
177
180
|
write_sidecar
|
|
@@ -198,12 +201,17 @@ module Samagotchi
|
|
|
198
201
|
rescue StandardError
|
|
199
202
|
nil
|
|
200
203
|
end
|
|
204
|
+
wake_acceptor
|
|
201
205
|
end
|
|
202
206
|
|
|
203
207
|
# Stop the acceptor, release the socket and remove the sidecar, so clients
|
|
204
208
|
# don't have to find it stale. Does not stop the owning turn. Joins the
|
|
205
209
|
# acceptor thread so the process can exit cleanly (Ruby waits for a thread
|
|
206
210
|
# blocked in IO.select at VM shutdown). Safe to call again.
|
|
211
|
+
#
|
|
212
|
+
# The sidecar goes before the open streams close: a client that
|
|
213
|
+
# reconnects as its stream ends (chi web's proxy, an attached TUI) then
|
|
214
|
+
# finds no worker, or the next one, never this one's dead port.
|
|
207
215
|
def stop
|
|
208
216
|
@mutex.synchronize { @stopped = true }
|
|
209
217
|
begin
|
|
@@ -211,6 +219,7 @@ module Samagotchi
|
|
|
211
219
|
rescue StandardError
|
|
212
220
|
nil
|
|
213
221
|
end
|
|
222
|
+
wake_acceptor
|
|
214
223
|
@capture_handle&.unsubscribe
|
|
215
224
|
@accumulator_handle&.unsubscribe
|
|
216
225
|
@engine.running_turn_messages = nil
|
|
@@ -220,13 +229,14 @@ module Samagotchi
|
|
|
220
229
|
# A turn post killed between its enqueue and its reply looks failed to
|
|
221
230
|
# the web, which then queues the prompt again from the input file.
|
|
222
231
|
await_answers(REQUEST_GRACE_SECONDS)
|
|
232
|
+
remove_sidecar
|
|
223
233
|
@connection_threads.each { |t| t.kill rescue nil }
|
|
224
234
|
@connection_threads.clear
|
|
225
235
|
# Its relay watchers (RelayWatcher) end with it.
|
|
226
236
|
watchers = @mutex.synchronize { (@relay_watchers || []).tap { @relay_watchers = [] } }
|
|
227
237
|
watchers.each { |thread| thread.kill.join(RELAY_WATCHER_JOIN_SECONDS) }
|
|
228
238
|
@accept_thread&.join(2)
|
|
229
|
-
|
|
239
|
+
[@wake_reader, @wake_writer].each { |io| io&.close rescue nil }
|
|
230
240
|
nil
|
|
231
241
|
end
|
|
232
242
|
|
|
@@ -246,7 +256,10 @@ module Samagotchi
|
|
|
246
256
|
# with its own).
|
|
247
257
|
# The commands queued for after the running turn (queued_commands,
|
|
248
258
|
# TurnAccumulator#queued_commands) too.
|
|
249
|
-
#
|
|
259
|
+
# working_set: the running turn's last :working_set_updated ({items:,
|
|
260
|
+
# at:}, TurnAccumulator#working_set), which the messages lag; nil
|
|
261
|
+
# between turns and before the turn emitted one.
|
|
262
|
+
# @return [Hash] {messages:, current_turn:, working_set:, queued:, queued_commands:, recap:, saved_recap:, continue_offer:, pending_question:,
|
|
250
263
|
# guardrail_warning:, plugin_warning:, init_tasks:, cards:, commands:, chi_version:, event_seq:, event_id:}
|
|
251
264
|
def snapshot
|
|
252
265
|
@engine.synchronize_events do
|
|
@@ -254,6 +267,7 @@ module Samagotchi
|
|
|
254
267
|
{
|
|
255
268
|
messages: @engine.messages_checkpoint,
|
|
256
269
|
current_turn: current_turn_with_generation,
|
|
270
|
+
working_set: @accumulator.working_set,
|
|
257
271
|
queued: @accumulator.queued,
|
|
258
272
|
queued_commands: @accumulator.queued_commands,
|
|
259
273
|
recap: @accumulator.recap,
|
|
@@ -356,17 +370,25 @@ module Samagotchi
|
|
|
356
370
|
end
|
|
357
371
|
end
|
|
358
372
|
|
|
373
|
+
# #stop or #close_intake: the acceptor's select returns now.
|
|
374
|
+
def wake_acceptor
|
|
375
|
+
@wake_writer&.write_nonblock(".", exception: false)
|
|
376
|
+
rescue IOError
|
|
377
|
+
nil # closed by an earlier #stop
|
|
378
|
+
end
|
|
379
|
+
|
|
359
380
|
def accept_loop
|
|
360
381
|
loop do
|
|
361
382
|
break if stopped? || @server.closed?
|
|
362
383
|
|
|
363
384
|
begin
|
|
364
|
-
rs, = IO.select([@server], nil, nil, 0.5)
|
|
385
|
+
rs, = IO.select([@server, @wake_reader], nil, nil, 0.5)
|
|
365
386
|
rescue IOError
|
|
366
387
|
break
|
|
367
388
|
end
|
|
368
389
|
|
|
369
390
|
next unless rs
|
|
391
|
+
break if rs.include?(@wake_reader)
|
|
370
392
|
|
|
371
393
|
client =
|
|
372
394
|
begin
|
|
@@ -571,10 +593,12 @@ module Samagotchi
|
|
|
571
593
|
|
|
572
594
|
# /recap in an attached TUI: the saved recap, and a new one asked for at
|
|
573
595
|
# once (it arrives as :recap_ready). Answers mid-turn too.
|
|
574
|
-
# 200 {enabled:, saved:, request:, min_user_turns:}
|
|
596
|
+
# 200 {enabled:, saved:, request:, min_user_turns:}; off, session_off:
|
|
597
|
+
# whether the session's own setting (/recap off) turned it off.
|
|
598
|
+
# Returns [headers, status, body].
|
|
575
599
|
def handle_recap(_session_id, _body = nil)
|
|
576
600
|
recap = @engine.recap
|
|
577
|
-
return [{}, 200, { enabled: false }] unless recap
|
|
601
|
+
return [{}, 200, { enabled: false, session_off: @engine.recap_override == false }] unless recap
|
|
578
602
|
|
|
579
603
|
saved = @engine.saved_recap
|
|
580
604
|
[{}, 200, { enabled: true, saved: saved, request: @engine.request_recap.to_s, min_user_turns: recap.min_user_turns }]
|
|
@@ -1258,9 +1282,11 @@ module Samagotchi
|
|
|
1258
1282
|
|
|
1259
1283
|
def write_sidecar
|
|
1260
1284
|
# version: the chi this worker runs (chi update and the web's badge
|
|
1261
|
-
# report older ones); features: what a client may ask of it
|
|
1285
|
+
# report older ones); features: what a client may ask of it; epoch:
|
|
1286
|
+
# this worker's, as its event ids carry it.
|
|
1262
1287
|
WorkerSidecar.new(port: @port, bind: @bind, session_id: @session_id, started_at: Time.now.iso8601(3),
|
|
1263
|
-
version: Samagotchi::VERSION, input_format: @input_format, features: FEATURES
|
|
1288
|
+
version: Samagotchi::VERSION, input_format: @input_format, features: FEATURES,
|
|
1289
|
+
epoch: @epoch).write(session_dir)
|
|
1264
1290
|
rescue StandardError => e
|
|
1265
1291
|
Log.warn(:bridge, "sidecar_write_failed", echo: "Bridge: failed to write #{WorkerSidecar::FILE}: #{e.class}: #{e.message}", error: e.class.name)
|
|
1266
1292
|
end
|
|
@@ -19,11 +19,22 @@ module Samagotchi
|
|
|
19
19
|
# it without that event.
|
|
20
20
|
#
|
|
21
21
|
# With +rediscover+, a failed connection first looks for the session's
|
|
22
|
-
# live worker (its sidecar): a new one (a restart
|
|
23
|
-
# snapshot, announced by a
|
|
24
|
-
# "port" => ...}` first, since its
|
|
25
|
-
# on the old one's port is the plain
|
|
26
|
-
# the old ids and sends a reset.)
|
|
22
|
+
# live worker (its sidecar): a new one (a restart: a client with another
|
|
23
|
+
# BridgeClient#worker_key) is followed from its snapshot, announced by a
|
|
24
|
+
# synthetic `{"type" => "worker_changed", "port" => ...}` first, since its
|
|
25
|
+
# event ids start over. (A new worker on the old one's port is the plain
|
|
26
|
+
# reconnect: its Bridge can't replay the old ids and sends a reset.)
|
|
27
|
+
# Rediscovery may also give a reason ([nil, reason] while it retries,
|
|
28
|
+
# [:gone, reason] to stop): the stream_closed event carries the last one.
|
|
29
|
+
#
|
|
30
|
+
# Off by default, for a stream through chi web (SessionAccess::
|
|
31
|
+
# WebBridgeClient), where the client stays the same object across
|
|
32
|
+
# workers: +epoch_watch+ (a frame whose id has another epoch than the
|
|
33
|
+
# cursor's is a new worker: worker_changed first, the client's key
|
|
34
|
+
# updated), +announce_retries+ (stream_retrying once a reconnect fails,
|
|
35
|
+
# stream_resumed at the next frame), +idle_timeout+ (no byte, not even
|
|
36
|
+
# the Bridge's 15 s ping, for that long: a half-open connection,
|
|
37
|
+
# reconnected with the cursor).
|
|
27
38
|
class EventStream
|
|
28
39
|
DEFAULT_RECONNECT_DELAYS = [0.1, 0.25, 0.5, 1.0, 2.0].freeze
|
|
29
40
|
# Long enough for a restarted worker to boot (its Engine, the plugins).
|
|
@@ -40,12 +51,26 @@ module Samagotchi
|
|
|
40
51
|
# @param reconnect_delays [Array<Float>] sleeps between failed attempts;
|
|
41
52
|
# one more failure than there are delays gives up
|
|
42
53
|
# @param rediscover [#call, nil] → a BridgeClient for the session's live
|
|
43
|
-
# worker now, nil when there is none yet, or :gone to give up now
|
|
54
|
+
# worker now, nil when there is none yet, or :gone to give up now;
|
|
55
|
+
# or AttachLink#live_worker's pair, [one of those, a reason]
|
|
56
|
+
# @param epoch_watch [Boolean] see the class comment; the client takes
|
|
57
|
+
# worker_key= then
|
|
58
|
+
# @param announce_retries [Boolean] see the class comment
|
|
59
|
+
# @param idle_timeout [Float, nil] seconds; nil: a quiet stream is
|
|
60
|
+
# never cut (a Bridge on this machine)
|
|
44
61
|
# @yieldparam event [Hash] string-keyed event
|
|
45
62
|
def initialize(client, snapshot: true, client_id: nil, reconnect_delays: DEFAULT_RECONNECT_DELAYS, rediscover: nil,
|
|
46
|
-
&on_event)
|
|
63
|
+
epoch_watch: false, announce_retries: false, idle_timeout: nil, &on_event)
|
|
47
64
|
@client = client
|
|
65
|
+
# The worker followed: a client that keeps its object across
|
|
66
|
+
# workers (epoch_watch) changes its key, not itself.
|
|
67
|
+
@worker_key = client.worker_key
|
|
48
68
|
@rediscover = rediscover
|
|
69
|
+
@epoch_watch = epoch_watch
|
|
70
|
+
@announce_retries = announce_retries
|
|
71
|
+
@idle_timeout = idle_timeout
|
|
72
|
+
@retrying = false
|
|
73
|
+
@reason = nil
|
|
49
74
|
params = []
|
|
50
75
|
params << %w[snapshot 1] if snapshot
|
|
51
76
|
params << ["client_id", client_id] if client_id
|
|
@@ -96,18 +121,21 @@ module Samagotchi
|
|
|
96
121
|
|
|
97
122
|
if outcome == :events
|
|
98
123
|
failures = 0
|
|
124
|
+
@reason = nil
|
|
99
125
|
next
|
|
100
126
|
end
|
|
101
127
|
|
|
128
|
+
announce_retry
|
|
102
129
|
case rediscover_worker
|
|
103
130
|
when :moved
|
|
104
131
|
failures = 0
|
|
132
|
+
@reason = nil
|
|
105
133
|
next
|
|
106
|
-
when :gone then return finish("unreachable")
|
|
134
|
+
when :gone then return finish(@reason || "unreachable")
|
|
107
135
|
end
|
|
108
136
|
|
|
109
137
|
failures += 1
|
|
110
|
-
return finish("unreachable") if failures > @reconnect_delays.size
|
|
138
|
+
return finish(@reason || "unreachable") if failures > @reconnect_delays.size
|
|
111
139
|
|
|
112
140
|
sleep(@reconnect_delays[failures - 1])
|
|
113
141
|
end
|
|
@@ -128,11 +156,15 @@ module Samagotchi
|
|
|
128
156
|
yielded = false
|
|
129
157
|
parser = SSEParser.new
|
|
130
158
|
loop do
|
|
159
|
+
raise Errno::ETIMEDOUT if @idle_timeout && !sock.wait_readable(@idle_timeout)
|
|
160
|
+
|
|
131
161
|
chunk = sock.readpartial(4096)
|
|
132
162
|
parser.feed(chunk) do |frame|
|
|
133
163
|
event = parse(frame[:data])
|
|
134
164
|
next unless event
|
|
135
165
|
|
|
166
|
+
announce_resumed
|
|
167
|
+
watch_epoch(frame[:id]) if @epoch_watch
|
|
136
168
|
@last_event_id = frame[:id] if frame[:id]
|
|
137
169
|
yielded = true
|
|
138
170
|
@on_event.call(event)
|
|
@@ -172,18 +204,55 @@ module Samagotchi
|
|
|
172
204
|
def rediscover_worker
|
|
173
205
|
return nil unless @rediscover
|
|
174
206
|
|
|
175
|
-
found = @rediscover.call
|
|
207
|
+
found, reason = @rediscover.call
|
|
208
|
+
@reason = reason if reason
|
|
176
209
|
return :gone if found == :gone
|
|
177
|
-
return nil if found.nil? || found.
|
|
210
|
+
return nil if found.nil? || found.worker_key == @worker_key
|
|
178
211
|
|
|
179
212
|
@client = found
|
|
180
213
|
@last_event_id = nil
|
|
181
|
-
|
|
214
|
+
moved(found.port, found.worker_key)
|
|
182
215
|
:moved
|
|
183
216
|
rescue StandardError
|
|
184
217
|
nil
|
|
185
218
|
end
|
|
186
219
|
|
|
220
|
+
# The frame +id+'s epoch (EventId: "<seq>-<epoch>") differs from the
|
|
221
|
+
# cursor's: a new worker answers the old cursor (its Bridge sends a
|
|
222
|
+
# reset next, or this is it). Announced before the frame.
|
|
223
|
+
def watch_epoch(id)
|
|
224
|
+
epoch = id.to_s[/-(\w+)\z/, 1]
|
|
225
|
+
cursor = @last_event_id.to_s[/-(\w+)\z/, 1]
|
|
226
|
+
return if epoch.nil? || cursor.nil? || epoch == cursor
|
|
227
|
+
|
|
228
|
+
@client.worker_key = epoch
|
|
229
|
+
moved(nil, epoch)
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# Following another worker now: its key, and the worker_changed event
|
|
233
|
+
# (with its epoch for a stream through chi web).
|
|
234
|
+
def moved(port, key)
|
|
235
|
+
@worker_key = key
|
|
236
|
+
event = { "type" => "worker_changed", "port" => port }
|
|
237
|
+
event["epoch"] = key if @epoch_watch
|
|
238
|
+
@on_event.call(event) unless closed?
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# A reconnect failed: said once, until a frame comes again.
|
|
242
|
+
def announce_retry
|
|
243
|
+
return if !@announce_retries || @retrying || closed?
|
|
244
|
+
|
|
245
|
+
@retrying = true
|
|
246
|
+
@on_event.call({ "type" => "stream_retrying" })
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def announce_resumed
|
|
250
|
+
return unless @retrying
|
|
251
|
+
|
|
252
|
+
@retrying = false
|
|
253
|
+
@on_event.call({ "type" => "stream_resumed" })
|
|
254
|
+
end
|
|
255
|
+
|
|
187
256
|
def finish(reason)
|
|
188
257
|
@on_event.call({ "type" => "stream_closed", "reason" => reason }) unless closed?
|
|
189
258
|
end
|