samagotchi 0.160.0 → 0.161.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +86 -1
  3. data/docs/architecture.md +7 -3
  4. data/docs/cli.md +6 -4
  5. data/docs/context.md +10 -1
  6. data/docs/guardrails.md +12 -3
  7. data/docs/internals/bridge.md +21 -6
  8. data/docs/internals/bundles.md +24 -5
  9. data/docs/internals/objectives.md +8 -2
  10. data/docs/internals/remote-sessions.md +2 -1
  11. data/docs/memory.md +4 -2
  12. data/docs/plugins.md +13 -4
  13. data/docs/releasing.md +7 -4
  14. data/docs/scheduling.md +37 -5
  15. data/docs/sessions.md +14 -11
  16. data/docs/testing.md +9 -1
  17. data/lib/samagotchi/bridge/card_store.rb +31 -3
  18. data/lib/samagotchi/bridge/ring_buffer.rb +11 -1
  19. data/lib/samagotchi/bridge/sse_writer.rb +8 -5
  20. data/lib/samagotchi/bridge.rb +92 -42
  21. data/lib/samagotchi/bridge_client.rb +9 -6
  22. data/lib/samagotchi/broadcast_command.rb +24 -8
  23. data/lib/samagotchi/bundle_command.rb +56 -6
  24. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  25. data/lib/samagotchi/child_reports.rb +29 -23
  26. data/lib/samagotchi/client_id.rb +2 -0
  27. data/lib/samagotchi/compat.rb +32 -0
  28. data/lib/samagotchi/context_candidates_log.rb +10 -1
  29. data/lib/samagotchi/context_command.rb +1 -1
  30. data/lib/samagotchi/context_move.rb +6 -1
  31. data/lib/samagotchi/engine/events.rb +12 -0
  32. data/lib/samagotchi/engine/plugins.rb +1 -1
  33. data/lib/samagotchi/engine/steering.rb +2 -2
  34. data/lib/samagotchi/guardrails/child_boundary.rb +37 -4
  35. data/lib/samagotchi/guardrails/protected_paths.rb +8 -5
  36. data/lib/samagotchi/guardrails/targets.rb +25 -6
  37. data/lib/samagotchi/live_versions.rb +7 -2
  38. data/lib/samagotchi/memory_bundle/asset_installer.rb +59 -12
  39. data/lib/samagotchi/memory_bundle/file_state.rb +11 -0
  40. data/lib/samagotchi/memory_bundle/index_updater.rb +7 -4
  41. data/lib/samagotchi/memory_bundle/installer.rb +79 -12
  42. data/lib/samagotchi/memory_bundle/provenance.rb +158 -6
  43. data/lib/samagotchi/memory_bundle/shipped_update.rb +2 -1
  44. data/lib/samagotchi/memory_bundle/status.rb +1 -1
  45. data/lib/samagotchi/memory_bundle/system_bundle.rb +2 -0
  46. data/lib/samagotchi/memory_bundle/uninstaller.rb +27 -13
  47. data/lib/samagotchi/memory_orphans.rb +40 -9
  48. data/lib/samagotchi/note_command.rb +9 -1
  49. data/lib/samagotchi/note_delivery.rb +34 -24
  50. data/lib/samagotchi/note_move.rb +205 -0
  51. data/lib/samagotchi/objectives_command.rb +3 -1
  52. data/lib/samagotchi/owner_lock.rb +7 -1
  53. data/lib/samagotchi/project_init.rb +3 -3
  54. data/lib/samagotchi/review_start.rb +5 -3
  55. data/lib/samagotchi/schedule_command.rb +12 -0
  56. data/lib/samagotchi/schedules.rb +286 -48
  57. data/lib/samagotchi/send_command.rb +2 -15
  58. data/lib/samagotchi/session.rb +42 -5
  59. data/lib/samagotchi/session_access/local.rb +45 -17
  60. data/lib/samagotchi/session_access/remote.rb +11 -4
  61. data/lib/samagotchi/session_access/web_bridge_client.rb +2 -2
  62. data/lib/samagotchi/session_access.rb +16 -3
  63. data/lib/samagotchi/session_continue.rb +155 -21
  64. data/lib/samagotchi/session_delete_command.rb +3 -1
  65. data/lib/samagotchi/session_inbox.rb +41 -1
  66. data/lib/samagotchi/session_inbox_delivery.rb +254 -0
  67. data/lib/samagotchi/session_manager/delivery.rb +66 -12
  68. data/lib/samagotchi/session_manager/emptiness.rb +7 -2
  69. data/lib/samagotchi/session_manager/lifecycle.rb +2 -1
  70. data/lib/samagotchi/session_manager/listing.rb +4 -1
  71. data/lib/samagotchi/session_manager/removal.rb +130 -26
  72. data/lib/samagotchi/session_manager/spawn.rb +164 -104
  73. data/lib/samagotchi/session_manager.rb +1 -0
  74. data/lib/samagotchi/session_retention.rb +53 -23
  75. data/lib/samagotchi/session_setup.rb +27 -7
  76. data/lib/samagotchi/starting_marker.rb +93 -24
  77. data/lib/samagotchi/state_format.rb +18 -7
  78. data/lib/samagotchi/system_one/runs.rb +6 -1
  79. data/lib/samagotchi/system_one/runs_store.rb +18 -14
  80. data/lib/samagotchi/terminal_ui/attach_launcher.rb +7 -4
  81. data/lib/samagotchi/terminal_ui/attached_loop/composer.rb +5 -2
  82. data/lib/samagotchi/terminal_ui/attached_loop.rb +5 -0
  83. data/lib/samagotchi/terminal_ui/version_lines.rb +4 -2
  84. data/lib/samagotchi/terminal_ui.rb +2 -3
  85. data/lib/samagotchi/tools/delegate.rb +9 -4
  86. data/lib/samagotchi/tools/schedule_task.rb +5 -3
  87. data/lib/samagotchi/tools/send_note.rb +10 -4
  88. data/lib/samagotchi/turn_state.rb +108 -4
  89. data/lib/samagotchi/update_command.rb +7 -2
  90. data/lib/samagotchi/verify_fresh.rb +27 -9
  91. data/lib/samagotchi/version.rb +1 -1
  92. data/lib/samagotchi/web/app.rb +1 -1
  93. data/lib/samagotchi/web/public/app.js +2 -7
  94. data/lib/samagotchi/web/public/chains.js +4 -4
  95. data/lib/samagotchi/web/public/data.js +18 -2
  96. data/lib/samagotchi/web/public/info_bar_model.js +1 -1
  97. data/lib/samagotchi/web/public/session_fields.js +3 -2
  98. data/lib/samagotchi/web/public/session_state.js +2 -0
  99. data/lib/samagotchi/web/public/update_notice.js +2 -2
  100. data/lib/samagotchi/web/session_access_routes.rb +12 -8
  101. data/lib/samagotchi/web/session_control.rb +9 -3
  102. data/lib/samagotchi/web/session_create.rb +8 -4
  103. data/lib/samagotchi/web/session_hub.rb +50 -5
  104. data/lib/samagotchi/web/session_summary.rb +5 -2
  105. data/lib/samagotchi/worker.rb +45 -7
  106. data/lib/samagotchi/worker_boot.rb +22 -11
  107. data/lib/samagotchi/worker_inbound.rb +41 -8
  108. data/lib/samagotchi/worker_link.rb +4 -1
  109. data/lib/samagotchi/worker_presence.rb +106 -0
  110. data/lib/samagotchi/worker_sidecar.rb +3 -4
  111. metadata +5 -1
data/docs/sessions.md CHANGED
@@ -21,9 +21,9 @@ Sessions are plain files — no DB. Each session is `~/.local/state/samagotchi/s
21
21
  | `SAMAGOTCHI_SESSION_KEEP_STATUS` | (none) | CSV of statuses never auto-pruned |
22
22
  | `SAMAGOTCHI_SESSION_SWEEP_INTERVAL_HOURS` | `24` | Throttle lazy sweep |
23
23
 
24
- 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.
24
+ 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 its `*.json` and sidecar dir under the deleting owner lock, along with its plugin state, attached context, participant grants, System 1 runs and context-candidate records. Explicit delete and retention use the same cleanup path. Set both `DAYS=0` and `MAX=0` to retain forever.
25
25
 
26
- **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`, `WorkerBoot.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, 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`). Reminders go along: as it leaves, the worker writes them to `<id>/reminders.json` (0600: `session_id`, `written_at`, and each reminder's id, name, description, interval and next fire as wall-clock time; `ReminderCarry`), and its successor reads and deletes the file as it starts, before its idle scheduler runs. A restored reminder keeps its id and schedule; one that came due meanwhile fires once at once, and one further ahead than its interval (a clock that jumped) is due one interval from now. The file counts only for the same session within 2 minutes of `written_at`, and not once the session is stopped: otherwise it is deleted unread (log `reminders_carry_dropped`, at warn unless the session was stopped). A stop that lands while the worker leaves for a restart wins: the worker writes no file, and no successor starts (`WorkerBoot.run_session_loop`, log `restart_dropped`); an archive deletes the file of each session it archives. Once an exit or restart is taken (claimed before a stop-all stops anything), the first one decides how the worker leaves: a later request of the same kind is answered as that exit, one of another kind (a stop-all from another terminal after a restart, chi web's "Restart workers" after a `/exit`) gets 409 `held` with `reason` `leaving_exit`, `leaving_delete` or `leaving_restart`, and a restart says it was refused rather than "restarted". A write that fails keeps the worker up with its reminders (log `reminders_carry_failed`), and the caller reports no new worker. A reminder already due when the restart is asked runs its turn first, so the restart's wait (10 s) may report no new worker yet though the hand-off follows. A worker from before this still refuses a restart for its reminders (409 `reminders`). Only an asked restart carries them: the fallback's TERM (a worker that can't be asked, below), a stop, an idle exit (which reminders hold anyway) and a crash drop them as before. 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.
26
+ **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`, `WorkerBoot.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, 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`). Reminders go along: as it leaves, the worker writes them to `<id>/reminders.json` (0600: `session_id`, `written_at`, and each reminder's id, name, description, interval and next fire as wall-clock time; `ReminderCarry`), and its successor reads and deletes the file as it starts, before its idle scheduler runs. A restored reminder keeps its id and schedule; one that came due meanwhile fires once at once, and one further ahead than its interval (a clock that jumped) is due one interval from now. The file counts only for the same session within 2 minutes of `written_at`, and not once the session is stopped: otherwise it is deleted unread (log `reminders_carry_dropped`, at warn unless the session was stopped). A stop that lands while the worker leaves for a restart wins: the worker writes no file, and no successor starts (`WorkerBoot.run_session_loop`, log `restart_dropped`). So does one that lands as it leaves any other way (an idle exit, `/exit`, a signal) with input queued or delegate reports waiting: no new worker un-stops it (log `respawn_dropped`), and what waits stays for the next start. A send after the stop still un-stops it, as a send always does: one that reaches a leaving worker clears the stop, so the leave's new worker runs it (log `send_unstopped_leaving`); an archive deletes the file of each session it archives. Once an exit or restart is taken (claimed before a stop-all stops anything), the first one decides how the worker leaves: a later request of the same kind is answered as that exit, one of another kind (a stop-all from another terminal after a restart, chi web's "Restart workers" after a `/exit`) gets 409 `held` with `reason` `leaving_exit`, `leaving_delete` or `leaving_restart`, and a restart says it was refused rather than "restarted". A write that fails keeps the worker up with its reminders (log `reminders_carry_failed`), and the caller reports no new worker. A reminder already due when the restart is asked runs its turn first, so the restart's wait (10 s) may report no new worker yet though the hand-off follows. A worker from before this still refuses a restart for its reminders (409 `reminders`). Only an asked restart carries them: the fallback's TERM (a worker that can't be asked, below), a stop, an idle exit (which reminders hold anyway) and a crash drop them as before. 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.
27
27
 
28
28
  **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 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.
29
29
 
@@ -39,9 +39,9 @@ A new session field is one `Session::FIELDS` row, plus a `SessionSetup` member w
39
39
 
40
40
  **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 `chi --resume ID -p TEXT --non-interactive` run, 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 wake or reminder turn's settle is recorded under its worker-minted `wake-…` id the same way, and a message merged into a turn as a steer follows that turn's id (`input/merged/`). 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 0.156 worker's reminder turn; a steer merged by a 0.156 worker), 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.
41
41
 
42
- **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, then CONT (a stopped worker, SIGSTOP or Ctrl-Z, runs its TERM handler only once continued, so it leaves at once instead of outliving the wait), 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 ⚙ panel, 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 (a stop also deletes a restart's `reminders.json` no successor took yet, so a later resume doesn't bring them back), 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.
42
+ **Stopping a worker:** `chi sessions stop ID` marks the session stopped (an `<id>/stopped` file holding when, `{"at"}`, not a session.json field, which a worker saving at that moment would write over; a resume removes it), sends its worker TERM, then CONT (a stopped worker, SIGSTOP or Ctrl-Z, runs its TERM handler only once continued, so it leaves at once instead of outliving the wait), 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 ⚙ panel, 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 (a stop also deletes a restart's `reminders.json` no successor took yet, so a later resume doesn't bring them back), 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.
43
43
 
44
- **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 one-turn `chi --non-interactive` run has (`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).
44
+ **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 one-turn `chi --non-interactive` run has (`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. A session whose worker is being started (the `<id>/starting` marker) is refused or stopped the same way, and the files go while the delete holds `owner.lock`, so a worker can't take the session in the middle; a worker that still had it loaded doesn't write it back. 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).
45
45
 
46
46
  <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 one-turn `chi --non-interactive` run 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 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 the `archived` toggle beside 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*; `continue →` is a plain button like `archive`, in the accent on an archived session, where it is the thing to do); archiving the open session stays on it, with an Undo toast.
47
47
 
@@ -110,7 +110,7 @@ chi sessions clean --dry-run --days 7 # test sessions older than 7 da
110
110
 
111
111
  - New sessions set `test_run:true` when `SAMAGOTCHI_ENV=test` or `RACK_ENV=test` or `CI` is set (explicit flag, `metadata_version` 2). Old sessions without the flag load as `test_run:false`.
112
112
  - Test runs are tagged and obey the same retention. `chi sessions clean` deletes every test session whatever its age (`--days N`: only those older than N days); a live worker or a `keep_status` status still keeps one. `chi sessions prune --test-only` applies the usual age and count rules to test sessions only.
113
- - A `chi scratch` session (`"scratch": true`) is deleted when its terminal leaves (its worker deletes it too once its last client is gone); one left behind when both died is deleted by the next sweep, `prune` or `clean`, whatever its age or status, once nobody owns it and its file is 30 s old (`SessionRetention::SCRATCH_START_GRACE`, the worker start grace: `chi scratch` saves the session before the worker it spawns takes the owner lock). `list` shows it as `[scratch]` (every text form; `scratch: true` in `--format json`); `chi web` never shows one. See [CLI: Scratch sessions](cli.md#scratch-sessions).
113
+ - A `chi scratch` session (`"scratch": true`) is deleted when its terminal leaves (its worker deletes it too once its last client is gone); one left behind when both died is deleted by the next sweep, `prune` or `clean`, whatever its age or status, once nobody owns it and no worker is being started for it (the `<id>/starting` marker every spawn writes, before `chi scratch`'s file is saved, until its worker holds the owner lock). `list` shows it as `[scratch]` (every text form; `scratch: true` in `--format json`); `chi web` never shows one. See [CLI: Scratch sessions](cli.md#scratch-sessions).
114
114
  - For ad-hoc manual QA use `SAMAGOTCHI_ENV=test XDG_STATE_HOME=/tmp/chi-test-$USER chi ...` to isolate from real state; the flag also marks sessions that `chi web` or an attached `chi` spawn (their workers inherit the environment), so `clean` finds them if they land in the real state.
115
115
 
116
116
  ## Session file
@@ -137,7 +137,7 @@ chi sessions clean --dry-run --days 7 # test sessions older than 7 da
137
137
  | `used_memory_names` | The memories the session read. | the Engine |
138
138
  | `project_root` | The project the session started in, stored because a worktree may be deleted later; `null` outside a repository or a marked folder (looked up again on read). | creation |
139
139
  | `project_id` | A marked project's id (its `.chi/project.yml`'s `id:`), saved beside `project_root`: the session's project context sources are keyed by it, so they follow a moved folder. Absent for any other project. | creation |
140
- | `preloaded_memory_names`, `muted_memory_names` | `--memory` and `--mute`, written before the worker starts so a respawn builds the same prompt. | creation |
140
+ | `preloaded_memory_names`, `muted_memory_names` | `--memory` and `--mute`, written before the worker starts so a respawn builds the same prompt; copied by a fork and a continue. A fork drops the reserved `system/delegated` memory so its `parent_id` does not make it a delegate; a continuation of a delegate child drops it because the new link is not a delegate. | creation |
141
141
  | `prompt_notes` | The model notes the system prompt carried at its last build (`[{name, scope, chars, digest}]`, see [Memory](memory.md#model-notes)). | the Engine, at each prompt build |
142
142
  | `parent_id` | The session that delegated this one or forked it (`ctx.sessions.fork`); `null` otherwise. See [Delegating](#delegating). | `SessionManager.spawn_session`; `ChildMove` writes a `parent.json` beside it instead of this file |
143
143
  | `scratch` | A `chi scratch` session, deleted when its terminal leaves. | creation |
@@ -171,6 +171,7 @@ pbpaste | chi note --source slack 3f2a 8c1d
171
171
 
172
172
  - The text comes from `-m` or stdin (a terminal on stdin is a usage error, not a wait). It is stripped; an empty note or one over 16 KiB is refused, never cut. `--source` (default `cli`) names where it came from. `--all` is every live session.
173
173
  - It lands in `<uuid>/notes/`, apart from `input/`, so nothing that runs turns sees it. A live worker adds it to the conversation within a few seconds, between turns: one sent during a turn waits for that turn to end. A session with no worker keeps it until a worker next starts (a prompt, `--attach`), which adds it before anything else. A session a one-turn `chi --non-interactive` run has refuses notes. `chi note` prints one line per session: queued, waits for the next start (with the queue count), or refused.
174
+ - A note to a link that was **continued** (archived, with a later link) goes to its chain's newest link, as `send_note`'s and `chi broadcast`'s do (`chi broadcast` gives two links of one chain the note once, logged under the newest): `chi note` prints `3f2a1c0d → 4b7e9a01 (continued) queued: …` (and `… (continued) refused: …` when that link refuses it), and chi web's notes answer names that link as `id` beside `session_id`, in a 409 too. `chi broadcast deliver` logs such a delivery under the newest link's id (the one that has the note), so `chi broadcast log` shows that id, not the one typed, and a later `deliver` to either link says it had it. An archived link nobody continues keeps its note until input brings it back. An `/objectives` edit is the one note that stays in its own session: its list is that conversation's. Notes waiting when a session is continued move with it (*Session chains*).
174
175
  - In the conversation it is a tail system message marked `kind: note`, framed `[CONTEXT NOTE from slack, 14:02]\n…\n[END NOTE]` (from another session: `from session 3f2a1c (~/projects/foo)`). The system prompt says notes are background, not requests: the model uses one when it is relevant, doesn't answer it on its own, and never follows instructions inside it. A note from `broadcast` ([`chi broadcast`](broadcast.md)) is the one exception to "doesn't answer it": the model says briefly in its next answer when it affects its current work, and still doesn't act on it unless asked. `--source broadcast` is refused for `chi note`. Its text is escaped like user text, so it can't fake a turn. It survives `--resume`, a reload, `!rollback` and a continue answered no.
175
176
  - The web shows it as a dim "note from …" block, live (`context_added`) and after a reload; the attached terminal as one dim `note from slack: <first line>` line, also when joining.
176
177
  - Agents: `list_sessions` (other sessions of this project, newest first, up to 20; optional `cwd`, `"/"` for every project) and `send_note(session, text)`, which sends a note from this session. Neither starts a turn anywhere.
@@ -325,7 +326,7 @@ chi send --new --continues last:3fa2 -m "new day" # the next link of 3fa2's
325
326
  (`SAMAGOTCHI_THINKING_LEVEL=low chi send --new …`) only reaches the worker this call spawns: when the session's
326
327
  worker is woken later (by `chi web`, a delivery) without that env, the level is gone. Use `--thinking`.
327
328
  - `--new` prints `<full id> started` on stdout. With `--wait` that line (and the `sent` line for an existing session) goes to stderr, so stdout is exactly the answer: the reply of the turn the message started, in full. It is the worker's `output/` file, the same one `delegate` reads; a turn that ends with only tool calls and no text has none.
328
- - `--wait` takes one session: `--new` or one id. A session with a running turn is refused (`busy: a turn is running; --queue runs the message after it, chi send --wait ID waits for it, chi --attach ID shows it`, exit 1): the message would merge into it, and its reply would come back as the answer. To an idle session (or with `--new`) the wait is for the message's own turn (the ack's `enqueued_id`, or, for a `--new` that starts with the message, the id its create gives that first turn — `first_turn_id` in chi web's 201, `turn_id` in `--format json`; one merged into a turn that started at that moment gets that turn's reply). `--queue --wait` is the way in: the message runs as a turn of its own after the running one, and the wait is for that turn alone (the ack's `enqueued_id` names its reply file and its `last_turn`), so the running turn's reply and end, and a question it asks meanwhile, are not the answer (it keeps waiting through them; its `--timeout` still applies); a question its own turn asks is (exit 3). Two queued waits each get their own turn's answer. A session waiting for an answer is still refused. `--format json` adds `delivery`: `queue`, or `next_step` when the running turn ended before the message went in (it then ran at once, and the answer is still its own).
329
+ - `--wait` takes one session: `--new` or one id. A session with a running turn is refused (`busy: a turn is running; --queue runs the message after it, chi send --wait ID waits for it, chi --attach ID shows it`, exit 1): the message would merge into it, and its reply would come back as the answer. To an idle session (or with `--new`) the wait is for the message's own turn (the ack's `enqueued_id`, or, for a `--new` that starts with the message, the id its create gives that first turn — `first_turn_id` in chi web's 201, `turn_id` in `--format json`; one merged into a turn that started at that moment gets that turn's reply). A client makes one id for the delivery; `chi send` reuses it if a Bridge POST falls back to an input file, so a current worker runs it once and `--wait` follows that same id. `--queue --wait` is the way in: the message runs as a turn of its own after the running one, and the wait is for that turn alone (the ack's `enqueued_id` names its reply file and its `last_turn`), so the running turn's reply and end, and a question it asks meanwhile, are not the answer (it keeps waiting through them; its `--timeout` still applies); a question its own turn asks is (exit 3). Two queued waits each get their own turn's answer. A session waiting for an answer is still refused. `--format json` adds `delivery`: `queue`, or `next_step` when the running turn ended before the message went in (it then ran at once, and the answer is still its own).
329
330
  - 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 send --wait --turn T ID` for a turn whose id is known, else `chi send --wait ID waits again; chi --attach ID shows it`, as after a `--timeout`). There is no default timeout.
330
331
  - `--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.
331
332
  - `--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).
@@ -349,18 +350,19 @@ A session can **continue** an earlier one: "the next run of the same routine" (a
349
350
 
350
351
  `SessionContinue.run` makes the next link:
351
352
 
352
- - **What it copies:** the previous link's folder, its model (the name it was typed as, so an alias holds) unless the continue names one (`--model`, the web's `model`), and its own LLM context values. A model given is checked like a new session's (a host config.yml doesn't have refuses the continue, nothing archived). An inherited model whose host can't run it any more (gone from config.yml, `enabled: false`, an alias naming another host) **falls back to the default model** with a warning, `gone:org/model can't run (unknown host 'gone' in model …); started on <default>`, decided before the archive; with no default model configured the continue is refused. The carry note doesn't mention the model. The new link carries no transcript beyond the note's last answer, so a different model, or another provider, starts clean there. Its preview is the previous link's (until its first message is saved), unless it starts with a message. Its title (`/title`) is the previous link's, with or without a first message.
353
+ - **What it copies:** the previous link's folder, its model (the name it was typed as, so an alias holds) unless the continue names one (`--model`, the web's `model`), its own LLM context values, and its `--memory` and `--mute` lists. It does not carry the reserved `system/delegated` memory when the previous link is a delegate child: a continuation has no parent and is no longer a delegate. A model given is checked like a new session's (a host config.yml doesn't have refuses the continue, nothing archived). An inherited model whose host can't run it any more (gone from config.yml, `enabled: false`, an alias naming another host) **falls back to the default model** with a warning, `gone:org/model can't run (unknown host 'gone' in model …); started on <default>`, decided before the archive; with no default model configured the continue is refused. The carry note doesn't mention the model. The new link carries no transcript beyond the note's last answer, so a different model, or another provider, starts clean there. Its preview is the previous link's (until its first message is saved), unless it starts with a message. Its title (`/title`) is the previous link's, with or without a first message.
353
354
  - **The carry note:** the new link starts with a context note from `session chain` (shown in its transcript as `note from session chain`), queued before its worker spawns, so its first turn sees it: `This session continues 3fa2c1d0 (2026-10-08 09:12), the previous link of its chain. Its recap, as of 2026-10-08 18:02:` and the previous link's recap (`recap.json`; cut at 8000 characters). No recap (fewer user turns than a recap needs, recaps off, none written in time): `… It left no recap.` Then `Its last answer:` and the previous link's last answer, read after the recap wait, so its exact shas, paths and next steps survive: a longer one keeps its last 4000 characters, after a `…` (a wrap-up closes on what comes next). There is none when the newest bubble was a step that asked for tools, when a later turn failed or was cut before it answered, and on a review link (a continue doesn't copy the grants, so the next link's participants could differ); `session.carry_last_answer: false` leaves it out of every note. No other transcript. The note never makes a continue fail: it stays within the 16 KiB a note may hold, the answer taking what the head and recap leave, and a session file that can't be read gives no answer. An idle new link holding only the note is kept, not discarded as empty.
354
355
  - **Its attached context:** the previous link's movable sources (a command source by default; a pushed one stays) are copied to the new link before its worker starts, with their last text and the removed and muted markers, and a second note (`attached context`, after the carry note) names what moved and what stayed. A push into the archived link is then refused with the new link's id. See [Movable context](context.md#movable-context).
356
+ - **Its waiting notes:** the previous link's unabsorbed context notes move to the new link (`NoteMove.move`, the continue's last step before the worker starts, after the objectives' carry, which reads a pending `/objectives` edit from them first), file names kept, so they keep their ids and order and come ahead of the carry note. An `/objectives` note stays (the carry took its list), and so do the notes an earlier continue wrote into the previous link itself (a link continued before it ever started: its own carry note is replaced by the new one). A note an old worker had claimed and not deleted (`*.json.processing`) moves too, un-claimed (logged `notes_moved_claimed`): the previous link is archived, so left there it would be lost; if that worker had already added it to its conversation, the new link sees it once more, a rare duplicate rather than a loss. A note sent to the previous link after that goes to the new one (*Context notes*). One that still lands in an earlier link (sent just as the continue saved the new link, or by an older chi that knows no chain) is adopted by the new link's worker on its next pass: it looks back along the chain, one listing at its start, through up to 8 archived links, each one whose next link is the one after it, stopping at a link back in the lists or gone, and takes from each only the notes from the creation of the link that continued it on, never an `/objectives` note; older ones (left by a continue before notes moved) stay with the archived link, logged once (`notes_left_old`). An earlier link that is brought back (input un-archives it) keeps its notes while it is back; archived again, the newest link's running worker adopts from it again, so a note it got meanwhile and never absorbed comes to the newest link.
355
357
  - **The recap first:** a previous link with a live worker is asked for a recap (the Bridge's `POST /recap`) and given up to 30 s to write it; one with none wrote its recap when its worker left.
356
- - **The previous link is archived** before the new one starts, by the archive's own rules (see *Archiving a session*): a turn running, a prompt queued or the step-limit question refuse it, a live idle worker is stopped, a session a one-turn `chi --non-interactive` run has is refused. Its finished delegates are archived with it, as any archive does.
358
+ - **The previous link is archived** before the new one starts, by the archive's own rules (see *Archiving a session*): a turn running, a prompt queued or the step-limit question refuse it, a live idle worker is stopped, a session a one-turn `chi --non-interactive` run has is refused. Its finished delegates are archived with it, as any archive does. Before archiving, the continue writes `<previous>/continue.json` with the reserved successor id. Session lists and the next continue repair an interrupted operation under `continue.lock`: if that successor's session file was not saved, they unarchive the previous link and remove the intent; if it was saved as its successor, they clear the intent and keep the archive.
357
359
  - **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 a one-turn `chi --non-interactive` run has (`in use by a one-turn chi --non-interactive run or an older chi's terminal; let it finish`). An older `chi web` or CLI still running shows a moved delegate under the archived link until it is restarted.
358
- - **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.
360
+ - **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, and the notes it moved with any sent to it meanwhile (never the continue's own: the carry note, the attached-context note, the objectives' list): its session file goes first, so a note sent to the previous link from then on stays there, then the notes go back, then its folder goes, and a note an older chi writes into it after that is given back too (a note for a session with no file and no folder is refused, never a stray folder); 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. Its `continue.json` intent remains in the previous link's folder too, so a later list can restore it if the successor was never saved. 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.
359
361
  - **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.
360
362
  - **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), and `moved_context`: `{moved: [names], stayed: [{name, why}]}`, the attached context it took (`why` `pushed` or `not movable`); 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`.
361
363
  - **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 one-turn `chi --non-interactive` run has, 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`, `in use by a one-turn chi --non-interactive run or an older chi's terminal; let it finish`), 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 with a recap of its own reads its auto title, else its recap's first sentence, instead (a link without one reads its chain's name, and a first link's recap names its chain). 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 the `archived` toggle 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.
362
364
  - **CLI:** `chi send --new --continues (ID|PREFIX|last:ID) [--model M] [-m TEXT]` (see *Starting a session*).
363
- - **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. A chain pruned by a schedule keeps its `anchor`: the kept window's oldest link still names a link that is gone, so the walk simply stops there; the page's chip counts from the surviving root (pruning renumbers the links) and its popover says `earlier links pruned` where the walk stops, rather than linking to a deleted session.
365
+ - **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. That is the rule: a message (`chi send`, the web composer, `delegate(session:)`) goes to the link it names; a note goes to the chain's newest link (*Context notes*). A chain pruned by a schedule keeps its `anchor`: the kept window's oldest link still names a link that is gone, so the walk simply stops there; the page's chip counts from the surviving root (pruning renumbers the links) and its popover says `earlier links pruned` where the walk stops, rather than linking to a deleted session.
364
366
 
365
367
  ## Schedules
366
368
 
@@ -409,6 +411,7 @@ sessions](cli.md#review-sessions)); both start idle and print the `chi share` li
409
411
  history comes along. Its preview is `review: docs/plan.md`, or a fork's `review of <the source's title>`.
410
412
  - **A fork isn't a continue:** the source is neither archived nor continued (no `continues`), and its delegates stay
411
413
  its own; a coordinator can open a review of its own session and keep working (`/coordinate review`).
414
+ - **Its setup:** a review fork carries the source's `--mute` list, but not its `--memory` preloads.
412
415
  - **Web:** `POST /api/sessions` takes `review: {subjects: [...], from: "<id>"}` instead of a `prompt` (a review
413
416
  session starts idle: `prompt`, `preview`, `idle` and `continues` beside it are 400 `invalid_review`). Without
414
417
  `from`, the subjects are relative to `dir` and the setup fields work as for any create; with `from`, the source's
data/docs/testing.md CHANGED
@@ -88,7 +88,7 @@ The session file goldens (`spec/session_file_goldens_spec.rb`, `spec/fixtures/se
88
88
  writes: every field away from its default, a new session, a newer chi's file, an old chi's file in a git repo, every key
89
89
  present as `null`, the plain writers' raw values, a running turn's checkpoint and the scrub (the `*_in.json` files are
90
90
  hand-written inputs, never regenerated); and the file each path that makes or rewrites a session writes (a fork, a
91
- continue, a delegate child, a plain spawn, a review, a Verify fresh child, a resume, a respawn, an archive), with
91
+ continue, a delegate child, a plain spawn, a review, a Verify fresh child, a respawn; a resume and an archive leave the bytes as they were), with
92
92
  `carry_matrix.json`, what each keeps of its source. They are pinned before Session's fields became one table and are kept
93
93
  as the file format's pins, not retired: a diff is a change to every session file on disk, so only a commit that means to
94
94
  change the format regenerates them (`UPDATE_GOLDENS=1 bundle exec rspec spec/session_file_goldens_spec.rb`).
@@ -144,6 +144,14 @@ either router adds its row there; a row where a UI differs on purpose (or a foun
144
144
  override and a `note`. The Ruby side runs each row with every core command (a new one fails its
145
145
  `CORE_ROUTES` check until listed there); a row's names that core lacks stand for plugin commands.
146
146
 
147
+ ## Compatibility corpus
148
+
149
+ `spec/compat/readers_spec.rb` reads the newest recorded release before the
150
+ current version (or the current corpus when it is the only one) through today's
151
+ session, Bridge, CardStore, schedules and bundle-record readers. `rake
152
+ release:bump[X.Y.Z]` writes `spec/compat/X.Y.Z/` using the current writers;
153
+ commit those small samples with the release so the next version exercises N-1.
154
+
147
155
  ## Levels
148
156
 
149
157
  Each level owns its own share; a detail is asserted once, at the lowest level that has it.
@@ -4,6 +4,7 @@ require "json"
4
4
  require "fileutils"
5
5
  require_relative "../atomic_file"
6
6
  require_relative "../events"
7
+ require_relative "../state_format"
7
8
  require_relative "card_places"
8
9
 
9
10
  module Samagotchi
@@ -62,6 +63,11 @@ module Samagotchi
62
63
  # count becomes +base_since+, and new openings add to it (#seed_v1).
63
64
  class CardStore
64
65
  CAPACITY = 20
66
+ FORMAT_VERSION = 2
67
+ FORMAT = StateFormat.new(
68
+ version: FORMAT_VERSION,
69
+ members: [StateFormat.required(:type), StateFormat.extra(:extra, in_version: FORMAT_VERSION)]
70
+ )
65
71
  NOTICE_TYPES = %i[hook_notice guardrail_warning empty_answer_retry steer_cut].freeze
66
72
  # Capped apart from the notices.
67
73
  LLM_CONTEXT_TYPES = %i[llm_context_edited].freeze
@@ -98,6 +104,8 @@ module Samagotchi
98
104
  @mutex = Mutex.new
99
105
  @entries = []
100
106
  @turn_log = []
107
+ @file_extra = {}
108
+ @format_read_only = false
101
109
  @running_turn = nil
102
110
  @continuing = false
103
111
  @failed_turn = nil
@@ -180,13 +188,14 @@ module Samagotchi
180
188
 
181
189
  def save
182
190
  return unless @path
191
+ return if @format_read_only
183
192
 
184
193
  entries = @entries.reject { |entry| entry[:type] == :guardrail_warning }
185
194
  # A session that never showed one gets no file.
186
195
  return if entries.empty? && !File.exist?(@path)
187
196
 
188
197
  FileUtils.mkdir_p(File.dirname(@path))
189
- saved = { "version" => 2, "turn_log" => pruned_log(entries), "entries" => entries }
198
+ saved = @file_extra.merge("version" => FORMAT_VERSION, "turn_log" => pruned_log(entries), "entries" => entries)
190
199
  saved["failed_turn"] = @failed_turn if @failed_turn
191
200
  AtomicFile.write(@path, JSON.generate(saved))
192
201
  rescue StandardError
@@ -222,15 +231,34 @@ module Samagotchi
222
231
  return unless @path && File.file?(@path)
223
232
 
224
233
  data = JSON.parse(File.read(@path), symbolize_names: true)
225
- return unless data.is_a?(Hash) && data[:entries].is_a?(Array)
234
+ return unless data.is_a?(Hash)
235
+
236
+ format_data = data.transform_keys(&:to_s)
237
+ format_data["entries"] = if data[:entries].is_a?(Array)
238
+ data[:entries].map { |entry| entry.is_a?(Hash) ? entry.transform_keys(&:to_s) : entry }
239
+ else
240
+ data[:entries]
241
+ end
242
+ document = FORMAT.read(format_data, collection: "entries")
243
+ if document.version > FORMAT_VERSION
244
+ raise StateFormat::InvalidDocument,
245
+ "cards.json has format version #{document.version}, newer than supported #{FORMAT_VERSION}; update chi before writing it"
246
+ end
247
+ @file_extra = document.extra.except("version", "entries", "turns", "turn_log", "failed_turn")
248
+ return unless data[:entries].is_a?(Array)
226
249
 
227
250
  if data[:turn_log].is_a?(Array)
228
251
  @turn_log = data[:turn_log].grep(String)
229
252
  @failed_turn = data[:failed_turn] if data[:failed_turn].is_a?(String)
230
- @entries = seeded(data[:entries])
253
+ @entries = seeded(document.entries.map { |entry| entry.to_h.transform_keys(&:to_sym) })
231
254
  elsif data[:turns].is_a?(Integer)
232
255
  seed_v1(data)
233
256
  end
257
+ rescue StateFormat::InvalidDocument => e
258
+ @format_read_only = true
259
+ @turn_log = []
260
+ @entries = []
261
+ Log.warn(:bridge, "card_store_unreadable", error: e.message)
234
262
  rescue StandardError
235
263
  @turn_log = []
236
264
  @entries = []
@@ -18,13 +18,17 @@ module Samagotchi
18
18
  @capacity = [1, capacity].max
19
19
  @mutex = Monitor.new
20
20
  @events = [] # Array<{seq:, data:}>
21
+ @dropped = false
21
22
  end
22
23
 
23
24
  # Append +data+ keyed by +seq+ (drop oldest beyond capacity).
24
25
  def push(seq:, data:)
25
26
  @mutex.synchronize do
26
27
  @events << { seq: seq, data: data }
27
- @events.shift until @events.size <= @capacity
28
+ while @events.size > @capacity
29
+ @events.shift
30
+ @dropped = true
31
+ end
28
32
  nil
29
33
  end
30
34
  end
@@ -45,6 +49,12 @@ module Samagotchi
45
49
  @mutex.synchronize { @events.first&.[](:seq) }
46
50
  end
47
51
 
52
+ # @return [Boolean] whether a push has ever evicted an event: the window
53
+ # no longer starts where the capture did.
54
+ def dropped?
55
+ @mutex.synchronize { @dropped }
56
+ end
57
+
48
58
  def empty?
49
59
  @mutex.synchronize { @events.empty? }
50
60
  end
@@ -161,16 +161,19 @@ module Samagotchi
161
161
  def replay!(io, snapshot_seq)
162
162
  from_seq = @high_water
163
163
  oldest = @ring.oldest_seq
164
- if @foreign_cursor || from_seq > snapshot_seq || (from_seq.positive? && oldest && from_seq < oldest - 1)
164
+ if @foreign_cursor || from_seq > snapshot_seq || (oldest && from_seq < oldest - 1 && (from_seq.positive? || @ring.dropped?))
165
165
  # Cursor is from another worker (epoch), ahead of the engine (a
166
166
  # worker restarted, plain cursor) or behind the
167
167
  # ring's buffered window (overflow): the server cannot replay the
168
168
  # gap, so it emits a reset marker and the client re-syncs. A cursor
169
169
  # at oldest - 1 has seen everything before the window, so it
170
- # replays. A cold
171
- # connect (from_seq == 0) replays the whole served window, and a
172
- # fully caught-up cursor (from_seq == snapshot_seq) is NOT a reset
173
- # case — it gets an empty replay and holds for live events.
170
+ # replays. A cold connect (from_seq == 0) is behind the window too
171
+ # once the ring has evicted an event (it would start mid-turn), so
172
+ # it resets; on a ring that never evicted (its first seq may be
173
+ # above 1: events before the Bridge started) it replays the whole
174
+ # window. A fully caught-up cursor (from_seq == snapshot_seq)
175
+ # is NOT a reset case — it gets an empty replay and holds for live
176
+ # events.
174
177
  emit_reset(io)
175
178
  else
176
179
  @ring.events_in_range(after_seq: from_seq, to_seq: snapshot_seq).each { |record| write_event(io, record[:data]) }
@@ -160,6 +160,9 @@ module Samagotchi
160
160
  @connection_threads = []
161
161
  @stopped = false
162
162
  @mutex = Monitor.new
163
+ @post_turn_mutex = Mutex.new
164
+ @enqueued_requests = {}
165
+ @enqueued_request_order = []
163
166
  @refused_log_mutex = Mutex.new
164
167
  @refused_logged_at = {}
165
168
  @open_streams = 0
@@ -767,14 +770,23 @@ module Samagotchi
767
770
  def handle_cancel(_session_id, body)
768
771
  # Optional reason from JSON body
769
772
  reason = :manual
773
+ turn_id = nil
770
774
  if body && !body.strip.empty?
771
775
  parsed = parse_json(body)
772
776
  r = parsed.is_a?(Hash) ? (fetched(parsed, "reason") || fetched(parsed, "cancellation_reason")) : nil
773
777
  reason = CANCEL_REASONS.include?(r.to_s.strip) ? r.to_s.strip.to_sym : :manual
778
+ turn_id = fetched(parsed, "turn_id").to_s.strip if parsed.is_a?(Hash)
779
+ turn_id = nil if turn_id && turn_id.empty?
774
780
  end
775
781
 
776
782
  if @engine.turn_running? && @engine.active_cancel_controller
777
- ok = @engine.cancel_current_turn!(reason)
783
+ # With a turn_id the engine compares it to the running turn's under
784
+ # its lock; clients that send none (older ones) cancel whichever runs.
785
+ ok = turn_id ? @engine.cancel_current_turn!(reason, turn_id: turn_id) : @engine.cancel_current_turn!(reason)
786
+ if ok == :turn_mismatch
787
+ return [{}, 409, { error: "turn_mismatch", detail: "the running turn is not the one named", session_id: @session_id }]
788
+ end
789
+
778
790
  return [{}, 202, { status: "cancel_requested", session_id: @session_id, reason: reason.to_s }] if ok
779
791
 
780
792
  [{}, 409, { error: "cancel_failed", detail: "could not cancel", session_id: @session_id }]
@@ -1209,29 +1221,47 @@ module Samagotchi
1209
1221
  end
1210
1222
  end
1211
1223
 
1212
- enqueued_id = SecureRandom.uuid
1224
+ enqueued_id = fetched(parsed, "enqueued_id")
1225
+ enqueued_id = SessionInbox.new_enqueued_id unless SessionInbox.valid_enqueued_id?(enqueued_id)
1226
+ enqueue_post_turn(sid: sid, prompt: prompt, client_id: client_id, no_interrupt: no_interrupt, author: author,
1227
+ anchors: anchors, images: images, delivery: delivery, deadline: deadline, pending_limit: pending_limit,
1228
+ enqueued_id: enqueued_id)
1229
+ end
1230
+
1231
+ def enqueue_post_turn(sid:, prompt:, client_id:, no_interrupt:, author:, anchors:, images:, delivery:, deadline:,
1232
+ pending_limit:, enqueued_id:)
1213
1233
  # Before it is queued: a drain after this may have taken it (#cut_for_steer).
1214
- epoch = @engine.input_epoch
1215
- # Whether a queue waits for a running turn: read with the write under
1216
- # the event log, so no :turn_started or :turn_completed falls between.
1217
- # To an idle worker the queued file simply runs as the next turn.
1218
- queued_behind_turn = false
1219
- # Write and announce with the event log held: the worker can't emit
1220
- # this turn's :turn_started (or merge it mid-turn) before
1221
- # :turn_enqueued, and a failed write announces nothing.
1222
- enqueued = @engine.synchronize_events do
1223
- raise IntakeClosed if stopped?
1224
- next :expired if expired?("turn_expired", deadline, sid: sid, client_id: client_id)
1225
- next :too_many if pending_limit && pending_turns(author) >= pending_limit
1226
-
1227
- # The owner's input never goes into a participant's read-only
1228
- # review turn (a steer, a cut): it queues behind it as the owner's
1229
- # own turn, and the ack and :turn_enqueued say so.
1230
- delivery = Delivery::QUEUE if @accumulator.participant_turn?
1231
- queued_behind_turn = Delivery.queue?(delivery) && !@accumulator.current_turn.nil?
1232
- enqueue_turn(prompt: prompt, client_id: client_id, enqueued_id: enqueued_id,
1233
- no_interrupt: no_interrupt, images: images, delivery: delivery, author: author, anchors: anchors).tap do |ok|
1234
- next unless ok
1234
+ @post_turn_mutex.synchronize do
1235
+ epoch = @engine.input_epoch
1236
+ # Whether a queue waits for a running turn: read with the write under
1237
+ # the event log, so no :turn_started or :turn_completed falls between.
1238
+ # To an idle worker the queued file simply runs as the next turn.
1239
+ queued_behind_turn = false
1240
+ # Write and announce with the event log held: the worker can't emit
1241
+ # this turn's :turn_started (or merge it mid-turn) before
1242
+ # :turn_enqueued, and a failed write announces nothing.
1243
+ enqueued = @engine.synchronize_events do
1244
+ raise IntakeClosed if stopped?
1245
+
1246
+ existing = @enqueued_requests[enqueued_id]
1247
+ if existing
1248
+ next(existing[:prompt] == prompt.to_s ? :duplicate : :conflict)
1249
+ end
1250
+ next :duplicate if @engine.enqueued_id?(enqueued_id)
1251
+ next :expired if expired?("turn_expired", deadline, sid: sid, client_id: client_id)
1252
+ next :too_many if pending_limit && pending_turns(author) >= pending_limit
1253
+ next :duplicate unless @engine.reserve_enqueued_id(enqueued_id)
1254
+
1255
+ # The owner's input never goes into a participant's read-only
1256
+ # review turn (a steer, a cut): it queues behind it as the owner's
1257
+ # own turn, and the ack and :turn_enqueued say so.
1258
+ delivery = Delivery::QUEUE if @accumulator.participant_turn?
1259
+ queued_behind_turn = Delivery.queue?(delivery) && !@accumulator.current_turn.nil?
1260
+ unless enqueue_turn(prompt: prompt, client_id: client_id, enqueued_id: enqueued_id,
1261
+ no_interrupt: no_interrupt, images: images, delivery: delivery, author: author, anchors: anchors)
1262
+ @engine.forget_enqueued_id(enqueued_id)
1263
+ next false
1264
+ end
1235
1265
 
1236
1266
  enqueued_event = { type: :turn_enqueued, enqueued_id: enqueued_id, client_id: client_id, prompt: prompt.to_s }
1237
1267
  enqueued_event[:images] = images unless images.empty?
@@ -1239,26 +1269,43 @@ module Samagotchi
1239
1269
  enqueued_event[:author] = author if author
1240
1270
  @engine.announce(enqueued_event)
1241
1271
  @on_input&.call
1272
+ true
1273
+ end
1274
+ if enqueued == :duplicate
1275
+ ack = @enqueued_requests.dig(enqueued_id, :ack) ||
1276
+ { status: "accepted", enqueued_id: enqueued_id, session_id: @session_id }
1277
+ return [{}, 202, ack]
1278
+ end
1279
+ if enqueued == :conflict
1280
+ return [{}, 409, { error: "enqueued_id_conflict",
1281
+ detail: "enqueued_id was already accepted for a different prompt" }]
1242
1282
  end
1283
+ return deadline_passed("turn") if enqueued == :expired
1284
+ return [{}, 429, { error: "too_many_turns", detail: ParticipantMessages::TOO_MANY_TURNS }] if enqueued == :too_many
1285
+ return [{}, 500, { error: "enqueue_failed", detail: "could not write turn input" }] unless enqueued
1286
+
1287
+ # Queued (outside the event lock): a message sent as +cut+ cuts a
1288
+ # running generation that has only been thinking for long, unless a
1289
+ # plugin or a client chi doesn't know sent it. An image input never
1290
+ # merges mid-turn, so it never cuts. Nothing else cuts: a message
1291
+ # that says nothing (an older client, the web composer) goes in at
1292
+ # the running turn's next step.
1293
+ cut = nil
1294
+ cut = @engine.cut_for_steer(Steer.source_for_client(client_id), epoch: epoch) if images.empty? && Delivery.cut?(delivery)
1295
+ # A queue to an idle worker is a turn now: the ack says next_step.
1296
+ ack_delivery = Delivery.queue?(delivery) && !queued_behind_turn ? Delivery::NEXT_STEP : delivery
1297
+ ack = { status: "accepted", enqueued_id: enqueued_id, session_id: @session_id, delivery: ack_delivery }
1298
+ ack[:cut] = cut.to_s if cut
1299
+ ack[:cut_after] = @engine.steer_cut_after if cut == :waits
1300
+ remember_enqueued_request(enqueued_id, prompt, ack)
1301
+ [{}, 202, ack]
1243
1302
  end
1244
- return deadline_passed("turn") if enqueued == :expired
1245
- return [{}, 429, { error: "too_many_turns", detail: ParticipantMessages::TOO_MANY_TURNS }] if enqueued == :too_many
1246
- return [{}, 500, { error: "enqueue_failed", detail: "could not write turn input" }] unless enqueued
1247
-
1248
- # Queued (outside the event lock): a message sent as +cut+ cuts a
1249
- # running generation that has only been thinking for long, unless a
1250
- # plugin or a client chi doesn't know sent it. An image input never
1251
- # merges mid-turn, so it never cuts. Nothing else cuts: a message
1252
- # that says nothing (an older client, the web composer) goes in at
1253
- # the running turn's next step.
1254
- cut = nil
1255
- cut = @engine.cut_for_steer(Steer.source_for_client(client_id), epoch: epoch) if images.empty? && Delivery.cut?(delivery)
1256
- # A queue to an idle worker is a turn now: the ack says next_step.
1257
- ack_delivery = Delivery.queue?(delivery) && !queued_behind_turn ? Delivery::NEXT_STEP : delivery
1258
- ack = { status: "accepted", enqueued_id: enqueued_id, session_id: @session_id, delivery: ack_delivery }
1259
- ack[:cut] = cut.to_s if cut
1260
- ack[:cut_after] = @engine.steer_cut_after if cut == :waits
1261
- [{}, 202, ack]
1303
+ end
1304
+
1305
+ def remember_enqueued_request(id, prompt, ack)
1306
+ @enqueued_requests[id] = { prompt: prompt.to_s, ack: ack }
1307
+ @enqueued_request_order << id
1308
+ @enqueued_requests.delete(@enqueued_request_order.shift) while @enqueued_requests.size > TurnState::ENQUEUED_ID_HISTORY
1262
1309
  end
1263
1310
 
1264
1311
  # POST /session/:id/turn/dequeue {enqueued_id, client_id?, deadline?}:
@@ -1290,7 +1337,10 @@ module Samagotchi
1290
1337
  next [:expired, nil] if expired?("dequeue_expired", deadline, sid: session_id, enqueued_id: id)
1291
1338
 
1292
1339
  SessionInbox.take_queued_input(session_dir, id).tap do |status, _|
1293
- @engine.announce(type: :turn_dequeued, enqueued_id: id, client_id: client_id) if status == :removed
1340
+ if status == :removed
1341
+ @engine.drop_enqueued_id(id)
1342
+ @engine.announce(type: :turn_dequeued, enqueued_id: id, client_id: client_id)
1343
+ end
1294
1344
  end
1295
1345
  end
1296
1346
  dequeue_reply(taken, id, input)
@@ -8,6 +8,7 @@ require_relative "degrade"
8
8
  require_relative "worker_sidecar"
9
9
  require_relative "bridge_secret"
10
10
  require_relative "delivery"
11
+ require_relative "session_inbox"
11
12
  require_relative "chi_invocation"
12
13
  require_relative "bridge_client/stream_open"
13
14
  require_relative "bridge_client/sse_parser"
@@ -197,9 +198,10 @@ module Samagotchi
197
198
  # many of their turns may be running or waiting (429 too_many_turns past it)
198
199
  # @param anchors [TurnAnchors, Hash, nil] a Verify turn's claim, a Revise
199
200
  # turn's draft: their own body keys ("verify", "revises")
200
- def post_turn(prompt:, client_id: nil, no_interrupt: false, images: nil, delivery: nil, author: nil, pending_limit: nil,
201
- anchors: nil)
202
- body = { session_id: @session_id, prompt: prompt, client_id: client_id }
201
+ def post_turn(prompt:, client_id: nil, enqueued_id: nil, no_interrupt: false, images: nil, delivery: nil, author: nil,
202
+ pending_limit: nil, anchors: nil)
203
+ body = { session_id: @session_id, prompt: prompt, client_id: client_id,
204
+ enqueued_id: enqueued_id || SessionInbox.new_enqueued_id }
203
205
  body[:no_interrupt] = true if no_interrupt
204
206
  body[:author] = author if author
205
207
  body[:pending_limit] = pending_limit if author && pending_limit
@@ -246,10 +248,11 @@ module Samagotchi
246
248
  end
247
249
 
248
250
  # POST /session/:id/cancel. 202 = requested (body names the reason the
249
- # Bridge took), 409 = no active turn.
251
+ # Bridge took), 409 = no active turn, or (error "turn_mismatch") not the
252
+ # +turn_id+ named: nothing was cancelled.
250
253
  # @return [Response]
251
- def cancel(reason:)
252
- post("cancel", { reason: reason }, read_body: true)
254
+ def cancel(reason:, turn_id: nil)
255
+ post("cancel", { reason: reason, turn_id: turn_id }.compact, read_body: true)
253
256
  end
254
257
 
255
258
  # POST /session/:id/tasks/stop: stop a background task this session's