samagotchi 0.116.0 → 0.117.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 +24 -1
- data/docs/cli.md +28 -1
- data/docs/configuration.md +6 -0
- data/docs/internals/background-tasks.md +1 -1
- data/docs/sessions.md +3 -3
- data/lib/samagotchi/bridge.rb +88 -14
- data/lib/samagotchi/bridge_client.rb +13 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/config.rb +19 -3
- data/lib/samagotchi/hold_stop.rb +89 -0
- data/lib/samagotchi/prompt_warmup.rb +8 -3
- data/lib/samagotchi/session_access/web_bridge_client.rb +9 -4
- data/lib/samagotchi/session_manager.rb +3 -2
- data/lib/samagotchi/task_watch.rb +23 -5
- data/lib/samagotchi/terminal_ui/attached_loop.rb +130 -8
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +2 -0
- data/lib/samagotchi/web/public/app.js +17 -1
- data/lib/samagotchi/web/public/composer_size.js +14 -7
- data/lib/samagotchi/web/public/data.js +14 -0
- data/lib/samagotchi/web/public/index.html +16 -0
- data/lib/samagotchi/web/public/session_actions.js +39 -6
- data/lib/samagotchi/web/public/stop_sheet.js +126 -0
- data/lib/samagotchi/web/session_access_routes.rb +9 -6
- data/lib/samagotchi/web/session_control.rb +40 -3
- data/lib/samagotchi/worker.rb +57 -5
- data/lib/samagotchi/worker_idle_exit.rb +111 -9
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 31e109ef4c87aedb02323fa93499a53ad9e08ccc5384be8aff037f3ac99459db
|
|
4
|
+
data.tar.gz: c52c487f3f06b2a3d44bc9bc6463086aa4c5fd882cff83e8f5afee7cb89062f9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 44752dd2037da4b96c34028e5173dc4e7d5d26079ab048d153d97a63655289b78ec8114b98bf2b4db160d8596772137f147018e3a9c1bce64af8502893411686
|
|
7
|
+
data.tar.gz: 9e4e2eab0e6c1719e6599d3895596ffc23ef850e0a6540c54213470618578bca4ec2c0a39146edb46c5b15258a3edf5c3b3560a6a23b868af494cac62714e6b3
|
data/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,28 @@ and commands may change between minor versions. How releases are made:
|
|
|
8
8
|
|
|
9
9
|
## [Unreleased]
|
|
10
10
|
|
|
11
|
+
## [0.117.0] - 2026-10-26
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- `/exit` in an attached terminal **asks what to do with what still runs** when only things you can stop keep the
|
|
16
|
+
worker up (reminders, a watched background task, a delegate with a live worker): keep running in the background,
|
|
17
|
+
stop them and exit, or go back. Anything else that holds the worker (a running turn, queued prompts, another UI)
|
|
18
|
+
still refuses as before, and a delegate never holds the idle exit. See [cli.md](docs/cli.md#attached-terminal).
|
|
19
|
+
- The web's `stop session` asks the same first: a sheet (a bottom sheet on a phone) counts what still runs and offers
|
|
20
|
+
**Stop all**, **Stop session only** (the stop as it was: background tasks and delegates keep running) or
|
|
21
|
+
**Cancel**; with nothing to stop it is one tap as before. See [Sessions](docs/sessions.md).
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
|
|
25
|
+
- An unquoted `on`/`off` in `config.yml` now means that word for a setting that takes it
|
|
26
|
+
(`guardrails.system_one.mode: on`, `session.task_wakes: off`) instead of silently falling back to the default
|
|
27
|
+
(YAML 1.1 reads them as booleans). See [Configuration](docs/configuration.md).
|
|
28
|
+
- chi web's composer grip keeps the message box inside the window it is shown in: a height saved in a bigger window
|
|
29
|
+
is held to half of a smaller one (a rotated phone, a resized window) and comes back whole when the room is back.
|
|
30
|
+
|
|
31
|
+
Update with `chi update` (the system bundle 0.117.0), then restart `chi web`.
|
|
32
|
+
|
|
11
33
|
## [0.116.0] - 2026-10-25
|
|
12
34
|
|
|
13
35
|
### Added
|
|
@@ -4405,7 +4427,8 @@ and long-lived sessions.
|
|
|
4405
4427
|
- A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
|
|
4406
4428
|
hotkey panel that send selected text or the clipboard to your sessions.
|
|
4407
4429
|
|
|
4408
|
-
[Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.
|
|
4430
|
+
[Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.117.0...HEAD
|
|
4431
|
+
[0.117.0]: https://github.com/dm1try/samagotchi/compare/v0.116.0...v0.117.0
|
|
4409
4432
|
[0.116.0]: https://github.com/dm1try/samagotchi/compare/v0.115.0...v0.116.0
|
|
4410
4433
|
[0.115.0]: https://github.com/dm1try/samagotchi/compare/v0.114.0...v0.115.0
|
|
4411
4434
|
[0.114.0]: https://github.com/dm1try/samagotchi/compare/v0.113.0...v0.114.0
|
data/docs/cli.md
CHANGED
|
@@ -434,7 +434,34 @@ In an attached terminal:
|
|
|
434
434
|
doesn't wait out the idle timeout. It stays up while something still needs
|
|
435
435
|
it, and the detach line says what: a turn is running (Ctrl-C cancels it
|
|
436
436
|
first), prompts are queued, a continue offer is pending, another UI is
|
|
437
|
-
attached, or reminders are set.
|
|
437
|
+
attached, or reminders are set. When every hold is something you can stop —
|
|
438
|
+
reminders, a background task it watches for a [finish wake](internals/background-tasks.md#finish-wake),
|
|
439
|
+
a delegate with a live worker — it asks instead of refusing, even when that
|
|
440
|
+
is all there is:
|
|
441
|
+
|
|
442
|
+
```
|
|
443
|
+
This session still has:
|
|
444
|
+
• 2 reminders: api_health (every 20m), git push (every 5m)
|
|
445
|
+
• 1 background task: rails s (2m)
|
|
446
|
+
• 1 delegate: c1c1c1c1 "Fix the composer" (running a turn)
|
|
447
|
+
1. Keep running in the background (detach)
|
|
448
|
+
2. Stop them and exit
|
|
449
|
+
3. Back to the session
|
|
450
|
+
```
|
|
451
|
+
|
|
452
|
+
1 is today's detach. 2 cancels the reminders, stops the tasks the same way
|
|
453
|
+
`task_stop` does and stops the delegates the same way `chi sessions stop ID`
|
|
454
|
+
does, then exits as `/exit` does; the line names what it stopped (a child a
|
|
455
|
+
terminal owns, or a task that can't be stopped, is left running and logged).
|
|
456
|
+
Each entry says what it is: a reminder its schedule (`every 20m`, `every 1h`,
|
|
457
|
+
with `, due now` when its next turn has already come), a task how long it has
|
|
458
|
+
run, a child its title and what it is doing. 3 keeps the prompt open, and the
|
|
459
|
+
next `/exit` asks again; Ctrl-D, `/detach` and a line the ask doesn't
|
|
460
|
+
understand are not answers to it (Ctrl-D detaches, a stray line is re-asked).
|
|
461
|
+
A delegate counts only for this ask: it never holds the idle exit, so 1 leaves
|
|
462
|
+
it running and the worker idles out as usual. `/exit --delete` and `/archive`
|
|
463
|
+
don't ask — with nothing else holding the worker, a running child does not
|
|
464
|
+
stop them, as before. A web tab you just closed can count as
|
|
438
465
|
attached for about 30 s. When the worker exits, `chi --resume ID` or
|
|
439
466
|
`chi --attach ID` starts a new one with the conversation. Before it exits
|
|
440
467
|
(here and on the idle exit) the worker writes the session's recap if
|
data/docs/configuration.md
CHANGED
|
@@ -97,6 +97,12 @@ Behavior:
|
|
|
97
97
|
- Top level is a YAML mapping of sections (`default:`, `server:`, `recap:`, …)
|
|
98
98
|
and the maps described below. Keys are lower `snake_case`, one level per
|
|
99
99
|
dot: `server.read_timeout` is `server: {read_timeout: 600}`.
|
|
100
|
+
- Each key takes its own type: a number, a boolean (`true`/`false`, and
|
|
101
|
+
`yes`/`on`/`1`, `no`/`off`/`0`), text, or one of the words the setting
|
|
102
|
+
allows (`auto`, `audit`, `on`, …). YAML 1.1 reads an unquoted `on`, `off`,
|
|
103
|
+
`yes` or `no` as a boolean, so for a setting whose values are `on` and
|
|
104
|
+
`off` an unquoted `off` is the same as `"off"` (`guardrails.system_one.mode: off`,
|
|
105
|
+
`session.task_wakes: off`).
|
|
100
106
|
- A key chi doesn't read warns at start, with the closest known key:
|
|
101
107
|
`config: unknown key 'default.modle' (did you mean 'default.model'?)`.
|
|
102
108
|
Names you choose under the maps below (host names, model ids) don't warn.
|
|
@@ -56,7 +56,7 @@ A task the model leaves running (it ends its turn: "I'll check when it's finishe
|
|
|
56
56
|
- **The wake turn**: the first finished task runs a continue turn from client `task:<id>` (`last_turn.origin: "task"`, labelled `task <id8> finished` in the TUI and the web; the web's bell rings at its end however short), whose first message is the note (`turn_start`, `turn_id`, written before the turn's checkpoint so a restore keeps it). It counts toward `session.max_wakes` with the other wakes (`WorkerWakes#task_open?`: no continue offer, not paused, past the 2 s start grace). Several tasks finishing together: one wake turn, the others plain notes before it.
|
|
57
57
|
- **A plain note instead** (the same text without the last two lines, no turn start), written at once, with `queue`, the budget spent (the UIs hear `N finished background tasks noted; the model reads them with your next message` once per human input, the flag shared with the other wakes), wakes paused, a continue offer waiting, input queued, an exit asked for, or a **note-only** task: one started or waited on in a turn the user stopped (reasons `user`, `manual`, `ctrl_c`), since a wake telling the model to carry on would undo that Stop. A prompt's turn takes the finished ones as plain notes first.
|
|
58
58
|
- **Failure**: a wake turn that fails and kept nothing, or is stopped and restored, turns its note back into the plain one (`WorkerInbound#unmark_wake`, as for a context wake) and pauses wakes until the user writes.
|
|
59
|
-
- **Idle exit**: with `wake`, a watched task holds the worker's idle exit (`WorkerIdleExit`'s `:background_tasks`) until its wake is taken: while it runs, and once it finished until the next idle pass takes it (`TaskWatch#holding`, in memory, no `ps`), so one that ends between the pass's look and the exit's check still wakes the session. A still-running one holds `/exit` too (`TaskWatch#running`, read once per request by `WorkerIdleExit#request_hold`; the 409 lists the tasks, `{id, command}`, with a `hint` on stopping them, and the attached terminal names them and says to use the web's stop-task button or ask for `task_stop`), as reminders do. A note-only task holds neither (no wake would come for it), nor does one whose note, or the look for it (the conversation's task ids unreadable), failed 3 times (`TaskWatch::MAX_FAILURES`: it is still tried at each look, but can't keep the worker up for good). With `queue` nothing holds: nothing would wake for the end, so keeping the worker up isn't worth it. As the worker leaves (any exit but a stop, a signal or an `/exit --delete`), the tasks that finished since the last look are written as plain notes; a task that ends after the worker left gets no note (the model sees its end at its next look, as before). A forgotten dev server keeps the worker up until it is stopped; a crash of that server wakes the session.
|
|
59
|
+
- **Idle exit**: with `wake`, a watched task holds the worker's idle exit (`WorkerIdleExit`'s `:background_tasks`) until its wake is taken: while it runs, and once it finished until the next idle pass takes it (`TaskWatch#holding`, in memory, no `ps`), so one that ends between the pass's look and the exit's check still wakes the session. A still-running one holds `/exit` too (`TaskWatch#running`, read once per request by `WorkerIdleExit#request_hold`; the 409 lists the tasks, `{id, command, age_seconds}`, with a `hint` on stopping them, and the attached terminal names them and says to use the web's stop-task button or ask for `task_stop`), as reminders do. When the only holds are ones the user can stop, the 409 carries `holds` too and the terminal asks whether to stop them all (`HoldStop`, which stops these tasks the same way `task_stop` does). A note-only task holds neither (no wake would come for it), nor does one whose note, or the look for it (the conversation's task ids unreadable), failed 3 times (`TaskWatch::MAX_FAILURES`: it is still tried at each look, but can't keep the worker up for good). With `queue` nothing holds: nothing would wake for the end, so keeping the worker up isn't worth it. As the worker leaves (any exit but a stop, a signal or an `/exit --delete`), the tasks that finished since the last look are written as plain notes; a task that ends after the worker left gets no note (the model sees its end at its next look, as before). A forgotten dev server keeps the worker up until it is stopped; a crash of that server wakes the session.
|
|
60
60
|
- **The tool descriptions**: a worker's `execute` and `task_create` say "if you end your turn before it finishes, chi tells you when it does" (left out under `off` and in the REPL), fixed when the worker builds its Engine.
|
|
61
61
|
|
|
62
62
|
Work that needs a model, not a shell command, goes to a child chi session instead: `delegate` / `delegate_result` have the same shape (start, then wait) and return only the child's final reply; see [Sessions: Delegating](../sessions.md#delegating).
|
data/docs/sessions.md
CHANGED
|
@@ -19,7 +19,7 @@ Sessions are plain files — no DB. Each session is `~/.local/state/samagotchi/s
|
|
|
19
19
|
|
|
20
20
|
A session is deleted if **expired by age OR overflow by count** (unless `keep_status` or the live-owner guard: a session a worker or `chi` still has open is never pruned). A session's `status` is its turn state (`idle`/`running`), not whether a worker is alive. Orphan dirs without a `*.json` are never deleted, except skeleton-only ones (see *Empty sessions*). Deletion removes both `*.json` and sidecar dir atomically. Set both `DAYS=0` and `MAX=0` to retain forever.
|
|
21
21
|
|
|
22
|
-
**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, no background task it watches for a [finish wake](internals/background-tasks.md#finish-wake) still running or waiting for its wake turn (with `session.task_wakes: wake`; not one whose turn you stopped) 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`). As its Bridge stops, the worker marks the lock leaving (`OwnerLock#mark_leaving`: `leaving_at`, and `leave_wait`, its `recap.timeout`), since it keeps the lock while it writes the recap on leave: an `--attach` or `--resume` meanwhile (here, or a remote one through chi web's wake) prints `(waiting for the session to finish its recap…)` (`LaunchMode::RECAP_WAIT`) and waits for the release, up to that budget plus `OwnerLock::LEAVE_MARGIN` (5 s), before starting the new worker (`OwnerLock.await_release`). A lock held by a worker that isn't leaving (a live one, a stuck one) is left as before. 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 (for background tasks, which ones and how to stop them). 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.
|
|
22
|
+
**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, no background task it watches for a [finish wake](internals/background-tasks.md#finish-wake) still running or waiting for its wake turn (with `session.task_wakes: wake`; not one whose turn you stopped) 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`). As its Bridge stops, the worker marks the lock leaving (`OwnerLock#mark_leaving`: `leaving_at`, and `leave_wait`, its `recap.timeout`), since it keeps the lock while it writes the recap on leave: an `--attach` or `--resume` meanwhile (here, or a remote one through chi web's wake) prints `(waiting for the session to finish its recap…)` (`LaunchMode::RECAP_WAIT`) and waits for the release, up to that budget plus `OwnerLock::LEAVE_MARGIN` (5 s), before starting the new worker (`OwnerLock.await_release`). A lock held by a worker that isn't leaving (a live one, a stuck one) is left as before. 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 (for background tasks, which ones and how to stop them). When every hold is one the user can stop — reminders, a watched task still running, a delegate with a live worker — the 409 also carries `holds: [{kind:, items: [{id:, label:, detail:}]}]` (a reminder's detail is its schedule, `every 20m`/`every 1h`, `, due now` when it is already due), and an attached terminal asks what to do with them; its "stop them and exit" posts `{"stop": "all"}`, which clears the reminders, stops those tasks (`TaskRuntime.stop_task`, as `task_stop`) and stops those children (`SessionManager.stop_session`) before leaving, re-checking first that nothing the user can't stop appeared meanwhile (`HoldStop`). A delegate counts only for that ask: it never holds the idle exit, and an exit that deletes or archives the session does not ask about it. 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.
|
|
23
23
|
|
|
24
24
|
**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.
|
|
25
25
|
|
|
@@ -33,7 +33,7 @@ A session is deleted if **expired by age OR overflow by count** (unless `keep_st
|
|
|
33
33
|
|
|
34
34
|
**A worker killed mid-turn:** while a turn runs, its worker keeps it on disk (`TurnCheckpoint`): the session file has the turn so far (the prompt from the turn's start, then the finished tool steps and lines merged into it) and `interrupted_turn: {id, started_at, at, iteration, enqueued_id}` (`enqueued_id`: the input the turn runs, so any process can tell which turn is running; a continue turn carries its offer turn's), written as the turn starts, at the loop's first iteration boundary and then at most every 10 s (`updated_at` moves with it). The turn's end saves the real messages without the mark. A worker that died without ending its turn (SIGKILL, a crash of the process) leaves the mark, and loses what came after its last written boundary: at most the last 10 s of steps, plus the step in flight. Each checkpoint write also saves the turn's metrics so far to `analytics.json` (tokens, speeds, timings, its finished tool calls), as the record a killed turn leaves: canceled, `cancellation_reason: worker_stopped`; the turn's own end writes over it. The next worker to start for the session (a `chi send`, `--resume`, `--attach`, the web), or a `--no-shared` REPL resuming it, settles the turn before anything else (`TurnCheckpoint.settle`, log `interrupted_turn_settled`): a note `the previous turn was cancelled (worker-stopped) after Ns` goes at the tail, the status goes idle and the mark goes; `last_turn` stays the previous turn's (a new one saved before the turn a `chi send --wait` woke the worker for would end that wait at once); the dead turn's end is recorded under its `enqueued_id` instead, in `<id>/input/settled/<enqueued_id>` (a `last_turn` record: canceled, `cancel_reason: worker_stopped`), which only a wait for that very turn reads (`chi send --queue --wait`, a `chi send --wait -m` to an idle session or `--new`, `chi send --wait ID --turn`, a continue's wait): it ends `canceled` with `cancel_reason: worker_stopped`, detail "its worker exited mid-turn", instead of timing out. A wait with no turn id that saw the turn running ends the same way when the settle comes (`chi send --wait ID` with no message on a turn with no id, such as a session's first prompt or a reminder turn; a message merged into the turn as a steer), unless an input still waits or was claimed since: an unmerged message runs as its own turn next, and that turn's end is the answer. A reply the turn wrote before its worker died is still its answer. The session shows the turn's prompt and steps with that note; the dead turn is not run again, and the model reads on its next turn that it was cut.
|
|
35
35
|
|
|
36
|
-
**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 (the page's Stop shows the stop at once), or for the body's `wait` up to the same 10 s (`SessionManager::STOP_WAIT`): `chi --remote NAME sessions stop` sends it, so it waits as long as here. 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.
|
|
36
|
+
**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 (the page's Stop shows the stop at once), or for the body's `wait` up to the same 10 s (`SessionManager::STOP_WAIT`): `chi --remote NAME sessions stop` sends it, so it waits as long as here. Before that stop, the page's `stop session` button asks what the worker still has that the user can stop: `GET /api/sessions/:id/holds` (owner-only) answers the worker's `holds` — the same list the exit ask's 409 carries — always an array, empty when there is no live worker, one from before the route, or one that did not answer. With something, a sheet (a bottom sheet on a phone, a popover at the button otherwise — `stop_sheet.js`) lists them, at most three of a kind named and `+N more`, and offers **Stop all** (`POST /api/sessions/:id/holds/stop` → `HoldStop#stop_all`, then the stop), **Stop session only** (the stop exactly as it was) or **Cancel**. Nothing to ask about is the plain confirm, one tap as before. Both buttons stay because a stop only TERMs the worker: its reminders die with it, while a background task and a delegate are their own processes and keep running. 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.
|
|
37
37
|
|
|
38
38
|
**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).
|
|
39
39
|
|
|
@@ -94,7 +94,7 @@ chi sessions clean --dry-run --days 7 # test sessions older than 7 da
|
|
|
94
94
|
- Web notifications: when a session needs you while the tab is hidden or behind another window (a question or a guardrail approval, a plugin card with buttons during a turn such as check-in's, a failed turn, a turn done after 10 s or more; a delegated session only for its questions, a canceled turn never), the page title counts it, `(N) Chi`, until you come back to the tab. The bell at the right of the top bar also turns on OS notifications ("needs an answer", "needs approval", "needs you", "turn failed", "done in 42 s", under the session's first message; a click opens the session): its first click asks the browser for the permission, and the choice is kept per browser. Any session of the page's scope counts, not only the open one. What was already so when the page loaded never counts, and several chi tabs show one notification per event. A chi tab in front tells the others (a `BroadcastChannel`), so while you look at one, the ones behind neither notify nor count what it shows; coming back to a tab clears those events in the other tabs' titles too. The hub's `session` frames carry what this needs: `pending_question` (`{id, kind}`, kind `question` or `approval`), `pending_card` (`{id, bundle}`: the running turn's card with actions, from `pending_card.json` that the worker keeps in the session's folder while one is open; a card without actions, such as loop-guard's, never counts) and `last_turn` (`{outcome, ended_at, seconds, origin}`, saved in the session's JSON as the engine ends each turn).
|
|
95
95
|
- The web frontend is a zero-build ES-module stack in `lib/samagotchi/web/public/`: `data.js` (retrieval, typed SSE `openStream` for a session and `openEvents` for the session list), `sessions_list.js` (the list as a pure reducer over `GET /api/events`: a snapshot replaces it, an upsert keeps a known card in place, a removal drops it; `families()` groups delegates under their parent), `families_view.js` (a parent's family chip, its rows and the strip's popover; the chip and rows as lit-html templates), `keep_focus.js` (lit reuses a list's nodes by position: a redraw keeps the focus on the same item by its `data-id` / `data-name`), `chains.js` (session chains: the links from `continues`, the cards folded into the latest link's, its `#N` chip and rows as lit-html templates, the info bar's ← → links, a refused continue, and the fresh-link rule), `notify.js` (which of those events needs the user, for the notifications), `app.js` (presentation/state; a session with no live stream gets one when its `session` event says `bridge_up`, after one re-read: `event_seq` starts over in each worker, so a dropped stream is never resumed with its old cursor), `bubbles.js` (the history's bubbles between the turns' rows: output chunks, end lines, every client's prompt bubbles by `enqueued_id` with their queued/steered badges, the idle recap, guardrail warnings, hooks' notices, plugins' steers, command and note bubbles; lit-html templates, put in the history with the sticky follow by `createBubbles`), `cards_place.js` (where plugin cards and a snapshot's notices go in the history: a card added or updated in place by its id, a running turn's into its step, a snapshot's each above the first turn after it, a finished turn's notice in its step's rows and its question resolved where it was asked; a card's action buttons), `format.js` (pure formatters such as `previewOf`), `plain_line.js` (markdown out of the plain one-line places: the stage headline, a closed step's title, an answered question card's kept-reply line), `prompt_history.js` (the composer's ↑/↓ over the shared prompt history), `commands.js` (session commands: which composer lines are commands, the page's own replies, a `command_ran`'s bubble, the page's command bubbles kept across a re-read), `composer.js` (where a composer line goes, `routeComposerLine`: /cut and /queue, the start page's own replies, a session command or `!cmd`, a typo hint, else a turn; and a session command sent from the composer or a chip), `delivery_line.js` (the composer's row above the text field in a session, always there so the composer keeps its height: the key hint while idle, and the delivery line while a turn runs: where a message typed now goes in, from the step's phase, clock and size, whether "cut in now" can land, and the queue link for a worker with the `delivery` feature, as a pure `deliveryLine`, its lit-html template and `createDeliveryControl`, which ticks it and keeps a choice armed from a link; see docs/cli.md, "Typing during a turn"), `restore.js` (a failed turn's prompt handed back into this tab's composer, before or after its `/turn` ack), `lists_view.js` (the session lists: the strip and its pill, all sessions with its search, count and select mode, their cards, the family and chain popovers; the cards, tile and pill as lit-html templates, drawn as new nodes each time, and the lists' orders as pure functions), `stream_handlers.js` (the open session's stream event handlers, by event type, over the page's views and hooks; a snapshot replays through them too), `stage_view.js` (the stage view: the running turn pinned above the composer, then handed off into the history; its pure rules and texts in `stage_model.js`, its live slots (headline, now row, trail, ticks) in `stage_slots.js`, its cap and held height in `stage_frame.js`, the in-use watch that times the hand-off in `stage_watch.js`), `info_bar.js` (the session bar's info line as a lit-html template over a plain model: id, status, model, chain links, chips, ctx, speed, clock, worker badge), `info_bar_model.js` (that model and the bar's buttons' states, built from the page's session state, its list and its in-flight continues and restarts as pure functions; `app.js`'s `updateInfoBar` applies them every second while a turn runs), `session_state.js` (what the page knows about the open session: its empty state, and `sessionStateFrom`, the fields a `GET /sessions/:id` payload sets), `refs.js` (refs: a kind and a target on the element, a plugin's ref data (label, http(s) URL actions) bound in a WeakMap by `bindRef`, the targets per kind, show in turn, one delegated set of click, key and hover listeners), `ref_menu.js` (a ref's `▾` and its menu), `ref_scan.js` (the `path:line` refs in an answer's or a step's text that name a file the session's rows touched), `answer_refs.js` (the refs a plugin attached to an answer, its `display_refs`: bound to the answer's bubble, paired with its links and text, bound with `bindRef`), `markdown_place.js` (where rendered markdown and its refs go: a text as markdown HTML, its `path:line` refs, a final answer's rendering), `images.js` (image chips in the composer: paste, drop and the image button, the upload before a turn, the thumbnails and the lightbox), `composer_grip.js` (the composer's grip and fold: the drag, tap and keys, the fold per visit), `strip_grip.js` (the latest-sessions strip's grip, the composer grip's twin: hides and shows the strip), `storage.js` (the page's per-browser choices in localStorage, guarded: a choice that can't be read or written is not remembered), `unqueue.js` (the × on a message queued for after the turn: takes it back, its text into an empty composer or a toast), `toasts.js` (the page's toast stack: a timer each, sticky ones until acted on, the same text reusing its item), `toast_place.js` (where the toast sits: above the composer card, the stage and an open model panel), `dock_reserve.js` (what floats over the history: the history's bottom reserve (`--dock-h`) at the composer card's and the showing stage's height, a reader at the end kept there, and the toast's place measured for `toast_place.js`), `notice_links.js` (a hook notice line's http(s) URLs as links), `start_page.js` (the start page's model picker and its LLM context and thinking chips, a choice for the next new session only; a fresh chain link's picker and its `/model`), `session_actions.js` (Continue, Archive and its Undo, Delete and Stop, and the start page when the open session is gone), `send_turn.js` (the composer's send: a new session first on the start page, the route, the echo, the image upload, the `/turn` ack, a fast failure's restore and each error's place). Unit-tested via `npm test` (`spec/web/public/*.test.js`).
|
|
96
96
|
- Selecting a session in the web UI is read-only: `GET /api/sessions/:id` never spawns a worker (it reads a live worker's snapshot when one runs, else the session file). A prompt (`POST /turn`) or a command (`POST /command`) wakes the worker, and `/stream` briefly waits for a freshly-spawned bridge before answering. A caught-up SSE reconnect holds the stream open; `reset` markers are only sent for reconnects behind the ring window, or with a cursor from another worker (event ids are `<event_seq>-<epoch>`, one epoch per worker).
|
|
97
|
-
- The web session page's message box folds away for room to read. Drag the grip on its top edge (the pill that shows on hover; always on a phone) down, past the box by 32 px: the pill turns accent while releasing would hide it, and on release the box folds and the info bar stays as the page's bottom edge. The grip stays as a handle on the info bar's top edge, drawn always: pull it up (8 px opens it, and the box follows the pointer in the same gesture), click or tap it (opens it and focuses the box), or just type on the page (the key lands in the box) to bring it back; Enter or Space on the focused grip folds or opens too. A draft (text or images, which folding never clears) colours the handle with the accent, a session waiting on you with the attention colour; its tooltip says so. Stop turn moves into the info bar while it is folded. The fold is remembered per browser (`chi_composer_collapsed`), an archived session opens folded for the visit, and opening it there, like the page opening it itself (a restored line, a quote, continue →'s opener), is for that visit only. The fold keeps the height the grip set (`chi_composer_floor`): the box opens again at the height it had, and only a double-click on the grip forgets it. A click on the expanded grip hides the box as well: it folds 250 ms after the press, so a second click inside that window is read as a double-click and resets the height instead (a drag, Enter or Space act at once, and a drag after a click takes the wait back). The start page's grip only resizes.
|
|
97
|
+
- The web session page's message box folds away for room to read. Drag the grip on its top edge (the pill that shows on hover; always on a phone) down, past the box by 32 px: the pill turns accent while releasing would hide it, and on release the box folds and the info bar stays as the page's bottom edge. The grip stays as a handle on the info bar's top edge, drawn always: pull it up (8 px opens it, and the box follows the pointer in the same gesture), click or tap it (opens it and focuses the box), or just type on the page (the key lands in the box) to bring it back; Enter or Space on the focused grip folds or opens too. A draft (text or images, which folding never clears) colours the handle with the accent, a session waiting on you with the attention colour; its tooltip says so. Stop turn moves into the info bar while it is folded. The fold is remembered per browser (`chi_composer_collapsed`), an archived session opens folded for the visit, and opening it there, like the page opening it itself (a restored line, a quote, continue →'s opener), is for that visit only. The fold keeps the height the grip set (`chi_composer_floor`): the box opens again at the height it had, and only a double-click on the grip forgets it. That height never takes more than half of the window the box is drawn in: a floor saved in a bigger window is held to that half in a smaller one (a rotated phone, a resized window, another device on the same origin), and the saved floor itself stays, so the height comes back when the room does. A click on the expanded grip hides the box as well: it folds 250 ms after the press, so a second click inside that window is read as a double-click and resets the height instead (a drag, Enter or Space act at once, and a drag after a click takes the wait back). The start page's grip only resizes.
|
|
98
98
|
|
|
99
99
|
**Test-session hygiene:**
|
|
100
100
|
|
data/lib/samagotchi/bridge.rb
CHANGED
|
@@ -87,21 +87,36 @@ module Samagotchi
|
|
|
87
87
|
# one, POST /command answers 501
|
|
88
88
|
# @param on_exit_request [#call, nil] a client asks the worker to exit
|
|
89
89
|
# now: takes the client_id and delete: (the exit is to delete the
|
|
90
|
-
# session, /exit --delete)
|
|
91
|
-
#
|
|
92
|
-
#
|
|
90
|
+
# session, /exit --delete) or stop: ("all": stop everything the user
|
|
91
|
+
# can stop first), returns nil when the worker will leave or
|
|
92
|
+
# what keeps it up (a WorkerIdleExit::Hold: #reason, #tasks, #hint,
|
|
93
|
+
# #holds); called with the event log held. Without one, POST /exit
|
|
94
|
+
# answers 501
|
|
93
95
|
# @param exit_discards [#call, nil] after an exit the worker agreed to:
|
|
94
96
|
# whether it will delete the session as empty (the 200 says
|
|
95
97
|
# +discard+); without one the reply leaves the field out
|
|
98
|
+
# @param exit_stopped [#call, nil] after a stop-all exit: what it stopped
|
|
99
|
+
# (a HoldStop::Stopped) for the 200's +stopped+; nil leaves it out
|
|
100
|
+
# @param on_holds [#call, nil] what the user could stop in this worker
|
|
101
|
+
# right now (WorkerIdleExit#stoppable_holds) for GET /holds; without
|
|
102
|
+
# one that route answers 501
|
|
103
|
+
# @param on_holds_stop [#call, nil] stop every one of them (HoldStop#stop_all)
|
|
104
|
+
# without the worker exiting, for POST /holds/stop; returns the
|
|
105
|
+
# HoldStop::Stopped the 200's +stopped+ carries; without one that route
|
|
106
|
+
# answers 501
|
|
96
107
|
def initialize(engine:, state_dir:, session_id:, bind: DEFAULT_BIND,
|
|
97
108
|
port: 0, ring_capacity: DEFAULT_RING_CAPACITY,
|
|
98
109
|
heartbeat_interval: DEFAULT_HEARTBEAT_INTERVAL, input_format: nil, on_input: nil,
|
|
99
|
-
on_command: nil, on_exit_request: nil, exit_discards: nil
|
|
110
|
+
on_command: nil, on_exit_request: nil, exit_discards: nil, exit_stopped: nil,
|
|
111
|
+
on_holds: nil, on_holds_stop: nil)
|
|
100
112
|
@engine = engine
|
|
101
113
|
@on_input = on_input
|
|
102
114
|
@on_command = on_command
|
|
103
115
|
@on_exit_request = on_exit_request
|
|
104
116
|
@exit_discards = exit_discards
|
|
117
|
+
@exit_stopped = exit_stopped
|
|
118
|
+
@on_holds = on_holds
|
|
119
|
+
@on_holds_stop = on_holds_stop
|
|
105
120
|
@input_format = input_format
|
|
106
121
|
@state_dir = state_dir
|
|
107
122
|
@session_id = session_id
|
|
@@ -590,6 +605,8 @@ module Samagotchi
|
|
|
590
605
|
%w[POST turn/dequeue] => :handle_dequeue_turn,
|
|
591
606
|
%w[POST command] => :handle_command,
|
|
592
607
|
%w[POST exit] => :handle_exit_request,
|
|
608
|
+
%w[GET holds] => :handle_holds,
|
|
609
|
+
%w[POST holds/stop] => :handle_holds_stop,
|
|
593
610
|
%w[POST recap] => :handle_recap,
|
|
594
611
|
%w[POST relay] => :handle_relay,
|
|
595
612
|
%w[POST relay/status] => :handle_relay_status,
|
|
@@ -883,14 +900,21 @@ module Samagotchi
|
|
|
883
900
|
|
|
884
901
|
# A client asks the worker to exit now (`/exit` in the attached TUI), or
|
|
885
902
|
# with "restart": true to hand the session to a new worker on the newest
|
|
886
|
-
# chi installed (the "restart" feature).
|
|
887
|
-
#
|
|
888
|
-
#
|
|
889
|
-
#
|
|
890
|
-
#
|
|
891
|
-
#
|
|
892
|
-
#
|
|
893
|
-
#
|
|
903
|
+
# chi installed (the "restart" feature). "stop": "all" first stops every
|
|
904
|
+
# hold the user can stop; "ask": false leaves the delegate children out of
|
|
905
|
+
# the list (a UI that can't ask about them, as /archive). The worker
|
|
906
|
+
# decides with the event log held, so no POST /turn lands between its
|
|
907
|
+
# check and its answer; it leaves from its loop after this reply.
|
|
908
|
+
# 200 {status: "exiting", discard?: the session is empty and goes,
|
|
909
|
+
# stopped?: what a "stop": "all" exit stopped} ({status: "restarting"} for
|
|
910
|
+
# a restart), or 409 {status: "held", reason:} naming what keeps it up
|
|
911
|
+
# (with tasks: [{id:, command:, age_seconds:}] and a hint: how to stop
|
|
912
|
+
# them, for background_tasks). When every hold is one the user can stop,
|
|
913
|
+
# the 409 also carries holds: [{kind:, items: [{id:, label:, detail:}]}
|
|
914
|
+
# — reminders, running tasks, delegate children — for the terminal's ask.
|
|
915
|
+
# A past +deadline+ (see #handle_post_turn), checked with the event log
|
|
916
|
+
# held: 408 deadline_passed, the worker not asked (its client said it
|
|
917
|
+
# didn't stop).
|
|
894
918
|
# Returns [headers, status, body].
|
|
895
919
|
def handle_exit_request(session_id, body)
|
|
896
920
|
return [{}, 501, { error: "exit_unavailable" }] unless @on_exit_request
|
|
@@ -901,7 +925,13 @@ module Samagotchi
|
|
|
901
925
|
client_id = fetched(parsed, "client_id")
|
|
902
926
|
delete = fetched(parsed, "delete") == true
|
|
903
927
|
restart = fetched(parsed, "restart") == true
|
|
928
|
+
stop_all = fetched(parsed, "stop") == "all"
|
|
929
|
+
# A client that can ask about what still runs (an attached terminal's
|
|
930
|
+
# plain /exit) gets the delegates in the hold list; one that can't
|
|
931
|
+
# (/archive, and every worker's own last look) does not.
|
|
904
932
|
options = restart ? { delete: delete, restart: true } : { delete: delete }
|
|
933
|
+
options[:stop] = true if stop_all && !restart
|
|
934
|
+
options[:ask] = false if fetched(parsed, "ask") == false
|
|
905
935
|
deadline = fetched(parsed, "deadline")
|
|
906
936
|
return BAD_DEADLINE unless deadline_valid?(deadline)
|
|
907
937
|
|
|
@@ -918,13 +948,57 @@ module Samagotchi
|
|
|
918
948
|
|
|
919
949
|
body = { status: "exiting", session_id: @session_id }
|
|
920
950
|
body[:discard] = @exit_discards.call == true if @exit_discards
|
|
951
|
+
stopped = @exit_stopped&.call
|
|
952
|
+
body[:stopped] = stopped.to_h if stopped
|
|
921
953
|
return [{}, 200, body]
|
|
922
954
|
end
|
|
923
955
|
|
|
924
956
|
# A hold may name what it waits for (tasks: the background tasks a
|
|
925
|
-
# finish wake watches).
|
|
957
|
+
# finish wake watches). When every hold is one the user can stop, +holds+
|
|
958
|
+
# lists them for the terminal's ask: [{kind:, items: [{id:, label:,
|
|
959
|
+
# detail:}]} (docs/cli.md, /exit).
|
|
926
960
|
[{}, 409, { status: "held", reason: hold.reason.to_s, tasks: hold.tasks, hint: hold.hint,
|
|
927
|
-
session_id: @session_id }.compact]
|
|
961
|
+
holds: holds_json(hold.holds), session_id: @session_id }.compact]
|
|
962
|
+
end
|
|
963
|
+
|
|
964
|
+
# The exit ask's stoppable holds as JSON; nil when there are none, so an
|
|
965
|
+
# older client sees today's plain 409.
|
|
966
|
+
def holds_json(holds)
|
|
967
|
+
return nil if holds.nil? || holds.empty?
|
|
968
|
+
|
|
969
|
+
holds.map { |hold| { kind: hold.kind.to_s, items: hold.items.map { |item| item.to_h } } }
|
|
970
|
+
end
|
|
971
|
+
|
|
972
|
+
# GET /session/:id/holds: what the user could stop in this worker right
|
|
973
|
+
# now — the same holds the exit ask's 409 carries (reminders, the watched
|
|
974
|
+
# background tasks still running, the delegate children with a live
|
|
975
|
+
# worker), whatever else keeps the worker up: chi web's Stop asks about
|
|
976
|
+
# these before it TERMs the worker, and a Stop cancels a running turn
|
|
977
|
+
# anyway, so a turn is not a reason to hide them.
|
|
978
|
+
# 200 {session_id, holds: [{kind:, items: [...]}]} (no +holds+ when there
|
|
979
|
+
# are none); 501 holds_unavailable when this worker has no such step.
|
|
980
|
+
# Returns [headers, status, body].
|
|
981
|
+
def handle_holds(_session_id, _body = nil)
|
|
982
|
+
return [{}, 501, { error: "holds_unavailable" }] unless @on_holds
|
|
983
|
+
|
|
984
|
+
[{}, 200, { session_id: @session_id, holds: holds_json(@on_holds.call) }.compact]
|
|
985
|
+
end
|
|
986
|
+
|
|
987
|
+
# POST /session/:id/holds/stop: stop everything the user can stop (HoldStop#stop_all)
|
|
988
|
+
# without the worker leaving — the reminders cleared, the watched tasks
|
|
989
|
+
# killed as task_stop does, the delegate children stopped as
|
|
990
|
+
# `chi sessions stop` does. What the user can't stop (a turn running,
|
|
991
|
+
# another UI attached) is untouched. The web's Stop's "Stop all" runs
|
|
992
|
+
# this, then stops the worker.
|
|
993
|
+
# 200 {status: "stopped", session_id, stopped: {reminders:, tasks:,
|
|
994
|
+
# delegates:}}; 501 holds_unavailable when this worker has no such step.
|
|
995
|
+
# Returns [headers, status, body].
|
|
996
|
+
def handle_holds_stop(_session_id, _body = nil)
|
|
997
|
+
return [{}, 501, { error: "holds_unavailable" }] unless @on_holds_stop
|
|
998
|
+
|
|
999
|
+
Log.info(:bridge, "holds_stop")
|
|
1000
|
+
stopped = @on_holds_stop.call
|
|
1001
|
+
[{}, 200, { status: "stopped", session_id: @session_id, stopped: stopped.to_h }]
|
|
928
1002
|
end
|
|
929
1003
|
|
|
930
1004
|
# Queue a turn for this session through its file IPC (the worker polls
|
|
@@ -206,10 +206,12 @@ module Samagotchi
|
|
|
206
206
|
# error "not_found" = a worker older than the route, 408 = too late.
|
|
207
207
|
# @param client_id [String] the asking UI, whose own streams don't hold
|
|
208
208
|
# @return [Response]
|
|
209
|
-
def request_exit(client_id:, delete: false, restart: false)
|
|
209
|
+
def request_exit(client_id:, delete: false, restart: false, stop: false, ask: true)
|
|
210
210
|
body = { client_id: client_id }
|
|
211
211
|
body[:delete] = true if delete
|
|
212
212
|
body[:restart] = true if restart
|
|
213
|
+
body[:stop] = "all" if stop
|
|
214
|
+
body[:ask] = false unless ask
|
|
213
215
|
body[:deadline] = deadline
|
|
214
216
|
post("exit", body, read_body: true)
|
|
215
217
|
end
|
|
@@ -236,6 +238,16 @@ module Samagotchi
|
|
|
236
238
|
post("tasks/stop", { task_id: task_id }, read_body: true)
|
|
237
239
|
end
|
|
238
240
|
|
|
241
|
+
# POST /session/:id/holds/stop: stop everything this worker's user can
|
|
242
|
+
# stop (its reminders, the background tasks a finish wake watches that
|
|
243
|
+
# still run, its delegate children) without the worker exiting
|
|
244
|
+
# (Bridge#handle_holds_stop). 200 {status: "stopped", stopped:
|
|
245
|
+
# {reminders:, tasks:, delegates:}}; 501 for a worker without the route.
|
|
246
|
+
# @return [Response]
|
|
247
|
+
def stop_holds
|
|
248
|
+
post("holds/stop", {}, read_body: true)
|
|
249
|
+
end
|
|
250
|
+
|
|
239
251
|
# How long the approval relay's calls wait for a reply: a parent's Stop
|
|
240
252
|
# isn't held for READ_TIMEOUT by a child that doesn't answer.
|
|
241
253
|
RELAY_TIMEOUT = 5
|
data/lib/samagotchi/config.rb
CHANGED
|
@@ -370,6 +370,8 @@ module Samagotchi
|
|
|
370
370
|
# What a :bool value reads as (Config.coerce!), YAML 1.1's words and 1/0.
|
|
371
371
|
BOOL_WORDS = { "1" => true, "true" => true, "yes" => true, "on" => true,
|
|
372
372
|
"0" => false, "false" => false, "no" => false, "off" => false }.freeze
|
|
373
|
+
# The word an :enum takes for a boolean (an unquoted on/off is one in YAML 1.1).
|
|
374
|
+
BOOL_ENUM_WORDS = { true => "on", false => "off" }.freeze
|
|
373
375
|
|
|
374
376
|
# Fast lookup maps
|
|
375
377
|
BY_KEY = ENTRIES.each_with_object({}) { |e, h| h[e.key] = e }.freeze
|
|
@@ -397,8 +399,10 @@ module Samagotchi
|
|
|
397
399
|
# +raw+ as +entry+'s type, strictly: a value of another shape raises.
|
|
398
400
|
# :string as given (a YAML list joined with |), :integer, :float,
|
|
399
401
|
# :bool (true/yes/on/1, false/no/off/0/""), :enum in its canonical
|
|
400
|
-
# case
|
|
401
|
-
#
|
|
402
|
+
# case (an unquoted on/off, which YAML 1.1 reads as a boolean, is the
|
|
403
|
+
# word when the entry has it); nil for nil, and for a blank value of any
|
|
404
|
+
# other type than :string and :bool. #coerce is its lenient twin
|
|
405
|
+
# (config.yml, env).
|
|
402
406
|
# @raise [SessionConfig::Error] "KEY takes WHAT, not RAW"
|
|
403
407
|
def coerce!(entry, raw)
|
|
404
408
|
return nil if raw.nil?
|
|
@@ -414,7 +418,7 @@ module Samagotchi
|
|
|
414
418
|
when :integer then Integer(str, exception: false)
|
|
415
419
|
when :float then Float(str, exception: false)
|
|
416
420
|
when :bool then BOOL_WORDS[str.downcase]
|
|
417
|
-
when :enum then entry
|
|
421
|
+
when :enum then enum_word(entry, raw, str)
|
|
418
422
|
else str
|
|
419
423
|
end
|
|
420
424
|
raise SessionConfig::Error, "#{entry.key} takes #{takes(entry)}, not #{raw.to_s.inspect}" if value.nil?
|
|
@@ -422,6 +426,18 @@ module Samagotchi
|
|
|
422
426
|
value
|
|
423
427
|
end
|
|
424
428
|
|
|
429
|
+
# A value of +entry+'s enum, as written. YAML 1.1 reads an unquoted
|
|
430
|
+
# on/off/yes/no as a boolean, so `mode: on` reaches the enum branch as
|
|
431
|
+
# true: it is the entry's "on"/"off" when the entry has that word, and
|
|
432
|
+
# still invalid when it doesn't.
|
|
433
|
+
def enum_word(entry, raw, str)
|
|
434
|
+
found = entry.enum_values.find { |v| v.downcase == str.downcase }
|
|
435
|
+
return found if found
|
|
436
|
+
|
|
437
|
+
word = BOOL_ENUM_WORDS[raw]
|
|
438
|
+
word && entry.enum_values.include?(word) ? word : nil
|
|
439
|
+
end
|
|
440
|
+
|
|
425
441
|
# What +entry+'s type takes, as a refusal names it.
|
|
426
442
|
def takes(entry)
|
|
427
443
|
case entry.type
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "log"
|
|
4
|
+
require_relative "session_manager"
|
|
5
|
+
require_relative "tools/task_runtime"
|
|
6
|
+
|
|
7
|
+
module Samagotchi
|
|
8
|
+
# Stops what a session's user can stop themselves when they ask its worker
|
|
9
|
+
# to exit: the reminders its Engine holds, the background tasks it watches
|
|
10
|
+
# for a finish wake that still run, and its delegate children with a live
|
|
11
|
+
# worker (docs/sessions.md, "Worker idle exit"). Called by the exit ask's
|
|
12
|
+
# "stop them and exit" (Worker#exit_request with stop: true), and the same
|
|
13
|
+
# step the web's Stop will use.
|
|
14
|
+
#
|
|
15
|
+
# What the user can't stop from here (a turn running, prompts queued, a
|
|
16
|
+
# continue offer, another UI attached) is never touched: WorkerIdleExit
|
|
17
|
+
# refuses the exit for those before this is called.
|
|
18
|
+
class HoldStop
|
|
19
|
+
# What was stopped, by kind: reminder names, task commands, children's
|
|
20
|
+
# short ids. The 200's +stopped+ is this as JSON.
|
|
21
|
+
Stopped = Data.define(:reminders, :tasks, :delegates) do
|
|
22
|
+
def initialize(reminders: [], tasks: [], delegates: []) = super
|
|
23
|
+
|
|
24
|
+
def any? = reminders.any? || tasks.any? || delegates.any?
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# @param engine [Engine] #reminder_store
|
|
28
|
+
# @param task_watch [TaskWatch, nil] this session's finish-wake watch
|
|
29
|
+
# (nil with session.task_wakes: off)
|
|
30
|
+
# @param delegates [#call] the children to stop (ChildrenStatus::Child)
|
|
31
|
+
# @param state_dir [String] where the child sessions live
|
|
32
|
+
def initialize(engine:, task_watch:, delegates:, state_dir:)
|
|
33
|
+
@engine = engine
|
|
34
|
+
@task_watch = task_watch
|
|
35
|
+
@delegates = delegates
|
|
36
|
+
@state_dir = state_dir
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Stop every hold the user can stop, read again as it is done (one may
|
|
40
|
+
# have ended in the meantime). A stop that fails is logged and left out
|
|
41
|
+
# of the report.
|
|
42
|
+
# @return [Stopped]
|
|
43
|
+
def stop_all
|
|
44
|
+
Stopped.new(reminders: stop_reminders, tasks: stop_tasks, delegates: stop_delegates)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
# Reminders live only in this process, so clearing the store ends them;
|
|
50
|
+
# the names go to the report before it is emptied.
|
|
51
|
+
def stop_reminders
|
|
52
|
+
store = @engine.reminder_store
|
|
53
|
+
return [] unless store
|
|
54
|
+
|
|
55
|
+
names = store.reminders.values.map { |reminder| reminder[:name] }
|
|
56
|
+
store.clear_all
|
|
57
|
+
names
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# The watched tasks still running, stopped the way task_stop and the
|
|
61
|
+
# web's stop-task button do (TaskRuntime.stop_task, by: "user").
|
|
62
|
+
def stop_tasks
|
|
63
|
+
return [] unless @task_watch
|
|
64
|
+
|
|
65
|
+
@task_watch.running.filter_map do |task|
|
|
66
|
+
record, error = Tools::TaskRuntime.stop_task(task.id, by: "user")
|
|
67
|
+
if record.nil?
|
|
68
|
+
Log.warn(:worker, "exit_stop_task_failed", task_id: task.id, error: error.to_s)
|
|
69
|
+
next nil
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
task.command
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# The delegate children, stopped as `chi sessions stop` stops a session
|
|
77
|
+
# (SessionManager.stop_session: its turn canceled, its worker TERMed).
|
|
78
|
+
# One owned by another terminal is left running and said so in the log.
|
|
79
|
+
def stop_delegates
|
|
80
|
+
@delegates.call.filter_map do |child|
|
|
81
|
+
SessionManager.stop_session(child.id, state_dir: @state_dir)
|
|
82
|
+
child.short_id
|
|
83
|
+
rescue StandardError => e
|
|
84
|
+
Log.warn(:worker, "exit_stop_delegate_failed", child: child.short_id, error: e.class.name, msg: e.message)
|
|
85
|
+
nil
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -29,6 +29,10 @@ module Samagotchi
|
|
|
29
29
|
# restores the state. When /slots can't say, the warm-up runs.
|
|
30
30
|
class PromptWarmup
|
|
31
31
|
MODES = %w[auto off].freeze
|
|
32
|
+
# The words cache.warmup takes, in the shape YAML hands them: an unquoted
|
|
33
|
+
# on/off arrives as true/false.
|
|
34
|
+
AUTO_WORDS = %w[auto on true 1].freeze
|
|
35
|
+
OFF_WORDS = %w[off false no 0].freeze
|
|
32
36
|
# Seconds #take_pin waits for the busy-slot check (Client#slot_status
|
|
33
37
|
# gives up within 3 s).
|
|
34
38
|
DECIDE_TIMEOUT = 3
|
|
@@ -36,12 +40,13 @@ module Samagotchi
|
|
|
36
40
|
STOP_JOIN_SECONDS = 1
|
|
37
41
|
|
|
38
42
|
# Whether cache.warmup allows a warm-up (auto, the default; off, false,
|
|
39
|
-
# no or 0 turn it off;
|
|
43
|
+
# no or 0 turn it off; on/true/1 (YAML's unquoted on) are auto; anything
|
|
44
|
+
# else warns once and is auto).
|
|
40
45
|
def self.enabled?
|
|
41
46
|
value = Config.get("cache.warmup").to_s.strip.downcase
|
|
42
|
-
return false if
|
|
47
|
+
return false if OFF_WORDS.include?(value)
|
|
43
48
|
|
|
44
|
-
unless value.empty? || value
|
|
49
|
+
unless value.empty? || AUTO_WORDS.include?(value) || @warned
|
|
45
50
|
@warned = true
|
|
46
51
|
Log.warn(:config, "invalid_value", echo: "Warning: invalid value for cache.warmup: #{value.inspect} (allowed: auto, off) — using auto",
|
|
47
52
|
key: "cache.warmup")
|
|
@@ -139,12 +139,17 @@ module Samagotchi
|
|
|
139
139
|
end
|
|
140
140
|
|
|
141
141
|
# POST …/exit: the worker asked to exit (chi web passes client_id on
|
|
142
|
-
# only when it is an attached TUI's)
|
|
143
|
-
#
|
|
144
|
-
#
|
|
145
|
-
|
|
142
|
+
# only when it is an attached TUI's), stop: true first stopping what
|
|
143
|
+
# the user can stop, ask: false leaving the delegate children out of
|
|
144
|
+
# the list (a UI that can't ask about them).
|
|
145
|
+
# @return [BridgeClient::Response] 200 {status: exiting, discard,
|
|
146
|
+
# stopped}, 409 held {reason, tasks, holds}, 408 late, 404 an older
|
|
147
|
+
# worker's
|
|
148
|
+
def request_exit(client_id:, delete: false, stop: false, ask: true)
|
|
146
149
|
body = { client_id: client_id }
|
|
147
150
|
body[:delete] = true if delete
|
|
151
|
+
body[:stop] = "all" if stop
|
|
152
|
+
body[:ask] = false unless ask
|
|
148
153
|
post(:exit, "exit", body)
|
|
149
154
|
end
|
|
150
155
|
|
|
@@ -1141,11 +1141,12 @@ module Samagotchi
|
|
|
1141
1141
|
# there is no live SSE or in-process cancel/answer).
|
|
1142
1142
|
# @param on_input [#call, nil] called after the Bridge queues a turn
|
|
1143
1143
|
def self.start_bridge(engine:, state_dir:, session_id:, on_input: nil, on_command: nil, on_exit_request: nil,
|
|
1144
|
-
exit_discards: nil)
|
|
1144
|
+
exit_discards: nil, exit_stopped: nil, on_holds: nil, on_holds_stop: nil)
|
|
1145
1145
|
require_relative "bridge"
|
|
1146
1146
|
Samagotchi::Bridge.new(
|
|
1147
1147
|
engine: engine, state_dir: state_dir, session_id: session_id, input_format: SessionInbox::INPUT_FORMAT,
|
|
1148
|
-
on_input: on_input, on_command: on_command, on_exit_request: on_exit_request, exit_discards: exit_discards
|
|
1148
|
+
on_input: on_input, on_command: on_command, on_exit_request: on_exit_request, exit_discards: exit_discards,
|
|
1149
|
+
exit_stopped: exit_stopped, on_holds: on_holds, on_holds_stop: on_holds_stop
|
|
1149
1150
|
).start
|
|
1150
1151
|
rescue StandardError => e
|
|
1151
1152
|
Log.error(:bridge, "start_failed", echo: "Bridge: failed to start for session #{session_id}: #{e.class}: #{e.message}", sid: session_id, error: e.class.name)
|