samagotchi 0.47.0 → 0.48.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 31cacb8f3d63a915a92684dcac667920837f96675f9c4256e3962cbfd95df5ef
4
- data.tar.gz: 29668be85fcc4178f470f15d8f2a1d1745cccb2a4e0391649ef85f8b5a5ad9bd
3
+ metadata.gz: b751e52e486a5e301e85e8dd0557b9786fc240d59becf71152eaee6b97c2176e
4
+ data.tar.gz: 1be48dc0d3898135b44b56bab594ee4af910669512b584c6d408536ed14f47bb
5
5
  SHA512:
6
- metadata.gz: e4c46bb7eee9e046679b9d4ca15540b17f6b1e15f92756c3c55d7733fb8d313b3e5b12b5077027950dc7f1671d46a11d6aaac1aa72ce3d4668475e87aa0ebea3
7
- data.tar.gz: 86e41f7cd8fe707cc8ad6914a72ce183b824f86d6bbc4c953a356ff9d5709776e49c0b254734f2e675061de8d26c2c66bfcd538fe9d8f28f5f4d7d727b7decbc
6
+ metadata.gz: 0b3d59478632ccee06384d6844b7a7e5d21dcc0e90042056d305a4763272f0ed1b1d0e9c7559fe7296915bc07c62148417883ad7084c18c488bd5d522f1e7f72
7
+ data.tar.gz: 726ae060c0f5f0b0c9238b24aa15601431b0648e297cba8709e9943f1e257da447009bea82b669178c859080ed79d2a25dc60ccc805b429f95bf0e7b0ba36839
data/CHANGELOG.md CHANGED
@@ -8,6 +8,22 @@ and commands may change between minor versions. How releases are made:
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [0.48.0] - 2026-10-09
12
+
13
+ ### Changed
14
+
15
+ - Continuing a session moves its open delegates to the new link instead of refusing: running, waiting, live or with
16
+ an unreported reply, at any depth below. They stay unarchived, fold under the new link, and their reports (one
17
+ each, none lost or repeated) reach it; each gets a note naming its new parent. Only a delegate on an older chi's
18
+ worker (restart it) or open in a chi REPL still refuses the continue. See
19
+ [docs/sessions.md](docs/sessions.md#session-chains).
20
+ - Web: the continue toast counts the delegates that moved (`1 open delegate moved`); `POST /api/sessions` with
21
+ `continues` answers `moved: [ids]`.
22
+ - CLI: `chi send --new --continues` prints `started (continues 3fa2c1d0; 2 delegates moved)`.
23
+
24
+ Update with `chi update` (no bundle changed), then restart `chi web`. A delegate whose worker started on an older
25
+ chi refuses to move until it is restarted.
26
+
11
27
  ## [0.47.0] - 2026-10-09
12
28
 
13
29
  ### Added
@@ -2215,7 +2231,8 @@ and long-lived sessions.
2215
2231
  - A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
2216
2232
  hotkey panel that send selected text or the clipboard to your sessions.
2217
2233
 
2218
- [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.47.0...HEAD
2234
+ [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.48.0...HEAD
2235
+ [0.48.0]: https://github.com/dm1try/samagotchi/compare/v0.47.0...v0.48.0
2219
2236
  [0.47.0]: https://github.com/dm1try/samagotchi/compare/v0.46.1...v0.47.0
2220
2237
  [0.46.1]: https://github.com/dm1try/samagotchi/compare/v0.46.0...v0.46.1
2221
2238
  [0.46.0]: https://github.com/dm1try/samagotchi/compare/v0.45.0...v0.46.0
data/docs/sessions.md CHANGED
@@ -182,7 +182,7 @@ chi send --new --continues last:3fa2 -m "new day" # the next link of 3fa2's
182
182
  - Exit codes with `--wait`: 0 answered; 3 the turn waits for an answer from you (a question, a hook's question, a guardrail approval, or the step-limit question: see [chi as a sub-agent](sub-agent.md#the-step-limit)), and the session keeps waiting: stderr gets the whole question (`waiting for an answer (approval): …`, its text, the numbered options, whether free text is allowed) and how to answer it (`chi answer ID --question QID --option N`, or `chi --attach ID`, the web); 4 `--timeout S` passed with the turn still running (it goes on; wait again); 1 the turn ended without a reply (canceled, failed or empty), the worker failed or vanished, or the session was stopped; 130 on Ctrl-C, which leaves the turn running (`still running: chi --attach ID`). There is no default timeout.
183
183
  - `--format json` (with `--wait`) prints one JSON object on stdout however the wait ends, for a script or a parent agent (see [chi as a sub-agent](sub-agent.md)): `{"status":"answered","session_id":…,"text":…}`; `{"status":"question",…,"question":{"id","kind","header","text","options","multi_select","allow_freeform","approval"},"answer_with":"chi answer ID --question QID --option N"}`, where `kind` is `question` (the model's), `hook` or `approval` (with the tool, command, cwd, rule, reason and offered scopes); `{"status":"running",…,"detail":…}` (exit 4, and on Ctrl-C); and `failed`, `canceled`, `no_answer`, `error`, `worker_gone` or `stopped` with a `detail` line (exit 1); `failed` adds `error_kind` and `retryable`, `canceled` adds `cancel_reason` and `stopped_by`, when known. A failure before the wait (no such session, a busy one) is `error` too. A usage error (exit 2) prints no JSON.
184
184
  - `--wait ID` with no message (no `-m`, nothing piped) sends nothing: it waits for the session's next reply and prints and exits as above. A running session is fine (that is the point), so after exit 3, 4 or 130 an agent waits again this way while the user answers in the web or `chi --attach`; the question pending when it starts doesn't count again. An idle session with no worker waits until something wakes one. With `--new`, or two ids, it is a usage error (exit 2).
185
- - `--continues ID` (with `--new`) starts the next link of that session's chain instead (see *Session chains*): in its folder, on its model and LLM context, so `--dir`, `--model` and `--llm-context*` are refused beside it; `ID` may be a prefix or `last:ID`, the chain's latest link. Without a message the new link starts idle; `--wait` then has nothing to wait for (a usage error). It prints `<full id> started (continues 3fa2c1d0)`; a refusal (continued already, naming the next link; a delegate still open; a turn running) is `chi send: refused: …`, exit 1.
185
+ - `--continues ID` (with `--new`) starts the next link of that session's chain instead (see *Session chains*): in its folder, on its model and LLM context, so `--dir`, `--model` and `--llm-context*` are refused beside it; `ID` may be a prefix or `last:ID`, the chain's latest link. Without a message the new link starts idle; `--wait` then has nothing to wait for (a usage error). It prints `<full id> started (continues 3fa2c1d0)`, with `; 2 delegates moved` inside the parentheses when open delegates moved to it; a refusal (continued already, naming the next link; an open delegate that can't move; a turn running) is `chi send: refused: …`, exit 1.
186
186
  - The 16 KiB cap applies: `git diff | chi send --new …` on a big diff is refused; name the branch in the message instead and let the session read it.
187
187
  - A session nobody attaches to stalls at its first question or guardrail ask until someone answers it (`chi answer`, the web, `chi --attach`), and its worker stays up meanwhile: it doesn't idle-exit while a question waits (`chi sessions list` shows it as `waiting`; `chi sessions stop ID` frees it). Otherwise it idle-exits after `session.idle_exit_minutes` like any worker. These are ordinary sessions: delete them like any other.
188
188
 
@@ -195,10 +195,12 @@ A session can **continue** an earlier one: "the next day of the same routine" (a
195
195
  - **What it copies:** the previous link's folder, its model (the name it was typed as, so an alias holds) and its own LLM context values. Its preview is the previous link's (until its first message is saved), unless it starts with a message.
196
196
  - **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), 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.` Never more than the recap: no transcript. An idle new link holding only the note is kept, not discarded as empty.
197
197
  - **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.
198
- - **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 plain REPL's session is refused. Its delegates are archived with it, as any archive does; so a continue is **refused while a delegate is still open** (at any depth, checked again after the recap wait): running, waiting for an answer, with a live worker, or with a reply its parent wasn't given (`ContinueRefused`, naming them). Wait for them, stop or archive them, then continue.
198
+ - **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 plain REPL's session is refused. Its finished delegates are archived with it, as any archive does.
199
+ - **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** (`ContinueRefused`, naming it): one whose live worker runs an older chi (`older chi worker 0.46.1; restart it`: `chi sessions restart <id>`, or stop it) or that is open in a chi REPL (close it there). An older `chi web` or CLI still running shows a moved delegate under the archived link until it is restarted.
200
+ - **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) 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.
199
201
  - **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 names a host config.yml no longer has. 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.
200
- - **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 not `dir`, `model` or `llm_context` (400 `invalid_continues`: the previous link's are taken). 201 is the new session's summary, `continues` set; 404 `not_found`; 400 `ambiguous_id` for a prefix of several sessions, `invalid_model` for a model whose host is gone; 409 `continued` with `next_id` (the link that continues it already, which the page opens instead), `open_children` with `ids`, `folder_gone`, `busy`, `scratch` or `owned_by_tui`. Every session summary carries `continues`.
201
- - **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 `Continue where we left off.` in the composer, not sent (the same line for every chain; a draft already there stays instead); the previous link is archived, a toast says so. 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 shows a toast that names each one (its preview and why the server says it is open: `running`, `waiting`, `live`, `unreported reply`), 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 preview and id in the tooltip. A link whose title is the opener and nothing else (its first message once sent) 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 `↩ day N` chip (`day N of M` when later links aren't listed) opens a popover of the other links, newest first (`day N`, its date, its recap's first sentence or its preview, `archived`), a click opens one; the chip shows a live dot or `waiting` for a link that has a worker or waits on the user, and a waiting link puts its chain's card first. All sessions folds nothing while a search is typed or "include archived" is on: every link is a card of its own then, its chip `day N of M`, with its own delegates folded in, so a search finds an earlier link and its delegates.
202
+ - **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 not `dir`, `model` or `llm_context` (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); 404 `not_found`; 400 `ambiguous_id` for a prefix of several sessions, `invalid_model` for a model whose host is gone; 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`.
203
+ - **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 `Continue where we left off.` in the composer, not sent (the same line for every chain; a draft already there stays instead); 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. A link continued already has no `continue →`: its `→` link goes to the next link (a stale page's continue answered 409 `continued` opens `next_id`). A continue refused for open delegates that can't move shows a toast that names each one (its preview and what the server says to do: `older chi worker 0.46.1; restart it`, `open in a chi REPL; close it there`), each a link to its page; any other refusal shows the server's words. The info bar of a link carries `← 2026-10-08` (the previous link's day) and, on an earlier link, `2026-10-10 →`, each a link with its preview and id in the tooltip. A link whose title is the opener and nothing else (its first message once sent) 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 `↩ day N` chip (`day N of M` when later links aren't listed) opens a popover of the other links, newest first (`day N`, its date, its recap's first sentence or its preview, `archived`), a click opens one; the chip shows a live dot or `waiting` for a link that has a worker or waits on the user, and a waiting link puts its chain's card first. All sessions folds nothing while a search is typed or "include archived" is on: every link is a card of its own then, its chip `day N of M`, with its own delegates folded in, so a search finds an earlier link and its delegates.
202
204
  - **CLI:** `chi send --new --continues (ID|PREFIX|last:ID) [-m TEXT]` (see *Starting a session*).
203
205
  - **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.
204
206
 
@@ -219,7 +221,7 @@ The `delegate` tool hands a task to a **child session**: an ordinary chi session
219
221
  - No relay where the parent can't host one (a `--no-shared` REPL, `--non-interactive`) or isn't waiting (`wait: false` until the next `delegate_result`): the approval waits on the child, with the web's bell, and the result's question block says to deny it or leave it for the user. The model's own questions and hooks' questions come back to the model as before. A grandchild's (a plugin's `ctx.sessions.fork` can make one) is relayed hop by hop: `delegate ab12 → cd34 asks:`.
220
222
  - **Delegate reports: a child's reply comes back by itself.** When a child ends a turn that was its parent's (its task, a follow-up, a continue the parent answered, a reminder: not a turn its user typed into it directly), it rings the parent: a small file in the parent's `children/` dir, written after the child's turn is saved. So does a question the parent's model decides (the child's own `ask_user_question`, the step-limit continue; not an approval or a hook's question, which the user answers on the child's card), the turn after such a question whoever starts it, and the child's worker crashing. The parent reads the child as `delegate_result` would and hands its model a **report**: the same text (`session:`, `status:`, the reply), under its own header (`[Delegate report, delivered by chi …; not your user's message. Tell your user what it found …]`), as an input message with `source: "delegate_report"`.
221
223
  - Parent mid-turn: the report joins the turn at its next iteration boundary (a "delegate report" bubble in the web, `delegate report> ab12cd34 answered: …` in the attached terminal).
222
- - Parent idle: it runs a turn for the report (a *wake turn*, `last_turn.origin: "delegate_report"`) within the worker's 5 s tick, or wakes a worker for it when the parent's has idle-exited. Several reports that wait together are one turn and one message. A stopped, archived or scratch parent, or one open in a `--no-shared` REPL, is not woken: the rings wait on disk and join the next turn it runs (the user's message first). A worker just started (`chi send` to a stopped parent) runs no wake turn in its first 2 s, so the message it was started for goes first.
224
+ - Parent idle: it runs a turn for the report (a *wake turn*, `last_turn.origin: "delegate_report"`) within the worker's 5 s tick, or wakes a worker for it when the parent's has idle-exited. Several reports that wait together are one turn and one message. A stopped, archived or scratch parent, or one open in a `--no-shared` REPL, is not woken: the rings wait on disk and join the next turn it runs (the user's message first). An archived parent that a later link continues wakes that link instead, which takes the rings its moved delegates left there (see *Session chains*). A worker just started (`chi send` to a stopped parent) runs no wake turn in its first 2 s, so the message it was started for goes first.
223
225
  - The parent's cursor per child (the newest reply it was given and the child as it was then) is in its `delegates.json`, shared with `delegate_result` and `wait: true`: a reply the model already had is never reported again, also after the parent's worker restarts. A ring is deleted only when the turn that took its report stays in the conversation (completed, canceled, or failed but kept: one that got to tool steps, or a reminder or continue turn, which keep what they read); a turn rolled back (a failed one that got nowhere, a canceled continue turn) leaves it for the next one (and the report isn't handed back to the composer). So the model reads each report once, unless a later `!rollback` erases a kept turn (what it read goes with it).
224
226
  - Limits: `session.max_wakes` (default `10`) wake turns in a row with no human input; past it the reports wait for the next message and the UIs show `N delegate reports waiting; they join your next message`. A wake turn that fails, or is stopped before the model call (a Stop, a `before_turn` hook's cancel), pauses wakes until the next human input; its reports' rings stay, so they join that input's turn, and a failure's note tells the model so. A continue offer waiting in the parent takes the reports into its continue turn instead.
225
227
  - `session.delegate_reports`: `wake` (default), `queue` (no wake turns: reports join the parent's next turn) or `off` (no rings; only `delegate_result` asks, as before). Env `SAMAGOTCHI_SESSION_DELEGATE_REPORTS`, `SAMAGOTCHI_SESSION_MAX_WAKES`.
@@ -474,7 +474,9 @@ module Samagotchi
474
474
  # than a version.
475
475
  # restart: POST /exit takes "restart": true (WorkerIdleExit#hold_for_restart).
476
476
  # task_stop: POST tasks/stop (#handle_task_stop), the web's stop-task button.
477
- FEATURES = %w[restart task_stop].freeze
477
+ # reparent: the worker reads Session::PARENT_FILE (its rings, each turn's
478
+ # start), so a continue may move it to the chain's new link (ChildMove).
479
+ FEATURES = %w[restart task_stop reparent].freeze
478
480
 
479
481
  # The routes, by method and what follows /session/:id/: a handler takes
480
482
  # the session id and the request body and returns [headers, status, body].
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.47.0
3
+ version: 0.48.0
4
4
  scope: system
5
5
  description: Default system memories — identity, self map, config modification protocol, memory guide and the delegated-session rules
6
6
  files:
@@ -0,0 +1,235 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+ require "time"
6
+ require_relative "atomic_file"
7
+ require_relative "session"
8
+ require_relative "session_inbox"
9
+ require_relative "worker_sidecar"
10
+ require_relative "log"
11
+ require_relative "tools/delegate_cursor"
12
+
13
+ module Samagotchi
14
+ # Loaded on first use: session_manager requires terminal_ui, which
15
+ # requires KernelLoop and so the tools (a require cycle otherwise).
16
+ autoload :SessionManager, File.expand_path("session_manager", __dir__)
17
+
18
+ # A continue moving the previous link's open delegates to the chain's new
19
+ # link (SessionManager.continue_session), instead of archiving them with
20
+ # it. A moved child gets a parent override (Session::PARENT_FILE), never a
21
+ # rewritten session file, so its live worker saving a stale copy changes
22
+ # nothing; its worker reads the override for its rings and at each turn's
23
+ # start (Bridge::FEATURES "reparent").
24
+ #
25
+ # #apply runs between the new link's save and its worker's spawn, while
26
+ # the link is #mark_starting (a ring from a moved child must not spawn a
27
+ # second worker from the child's process until the link's own holds it).
28
+ # Each step is idempotent,
29
+ # and an intent file (INTENT_FILE) in the new link's folder names the move
30
+ # until it is done: a worker that starts and finds one finishes the move
31
+ # (#adopt), and a failed continue moves the same children back.
32
+ module ChildMove
33
+ # <new link dir>/move.json: {from, ids}, while a move is under way.
34
+ INTENT_FILE = "move.json"
35
+ # <new link dir>/starting: {pid, at}, from the move to the worker's spawn.
36
+ STARTING_FILE = "starting"
37
+ # A starting marker older than this, or whose process is gone, is left
38
+ # over from a continue that died: it holds nothing back.
39
+ STARTING_STALE = 60.0
40
+ # Who the note to a moved child is from (ContextNote's label).
41
+ NOTE_SOURCE = "session chain"
42
+ # What a live child's worker must name in its sidecar to be moved.
43
+ FEATURE = "reparent"
44
+
45
+ # What a continue does with the previous link's children: +move+ the
46
+ # direct children whose subtree has an open node (they go to the new
47
+ # link, their subtrees with them), +refuse+ the open ones it can't move
48
+ # (SessionManager::OpenChild, why: an older chi worker or a chi REPL).
49
+ Plan = Data.define(:move, :refuse)
50
+ # A move under way: from the session, the children moved.
51
+ Intent = Data.define(:from, :ids)
52
+
53
+ module_function
54
+
55
+ # @param open [Array<SessionManager::OpenChild>] the previous link's open
56
+ # nodes, at any depth (SessionManager.open_children)
57
+ # @return [Plan]
58
+ def plan(prev_id, open:, state_dir:)
59
+ open_ids = open.map(&:id)
60
+ move = []
61
+ refuse = []
62
+ SessionManager.children_of(prev_id, state_dir: state_dir).each do |row|
63
+ subtree = [row[:id], *SessionManager.descendant_ids(row[:id], state_dir)]
64
+ next if (subtree & open_ids).empty?
65
+
66
+ if row[:archived]
67
+ # An open delegate below an archived one: the archive's cascade
68
+ # would take it, the move doesn't (only unarchived ones move).
69
+ refuse.concat(open.select { |c| subtree.include?(c.id) }.map do |c|
70
+ c.with(why: "under archived #{row[:id][0, 8]}; unarchive that one or archive this one")
71
+ end)
72
+ next
73
+ end
74
+
75
+ why = unmovable(row[:id], state_dir: state_dir)
76
+ if why
77
+ refuse << SessionManager::OpenChild.new(id: row[:id], short_id: row[:id][0, 8], why: why)
78
+ else
79
+ move << row[:id]
80
+ end
81
+ end
82
+ Plan.new(move: move, refuse: refuse)
83
+ end
84
+
85
+ # Why a child's live owner keeps it from moving: a chi REPL holds its
86
+ # own Session and never reads the override; a worker on an older chi
87
+ # neither. A worker with no sidecar yet is starting, on the newest chi
88
+ # installed (SessionManager.worker_command): it moves.
89
+ # @return [String, nil]
90
+ def unmovable(child_id, state_dir:)
91
+ owner = SessionManager.session_owner(child_id, state_dir: state_dir) or return nil
92
+ return "open in a chi REPL; close it there" if owner.tui?
93
+
94
+ sidecar = WorkerSidecar.live(Session.session_dir(child_id, state_dir: state_dir), unlink: false)
95
+ return nil if sidecar.nil? || sidecar.features.include?(FEATURE)
96
+
97
+ "older chi worker#{" #{sidecar.version}" if sidecar.version}; restart it"
98
+ end
99
+
100
+ # Move +ids+ (direct children of +from+) to +to+: the intent, the
101
+ # cursors +to+ lacks (delegates.json), the overrides, the rings waiting
102
+ # in +from+'s children/, a note to each child, then the intent goes.
103
+ # Safe to run again on the same move.
104
+ # @param undo [Boolean] a failed continue moving them back: its note says so
105
+ def apply(ids, from:, to:, state_dir:, undo: false)
106
+ return if ids.empty?
107
+
108
+ write_intent(to, Intent.new(from: from, ids: ids), state_dir: state_dir)
109
+ copy_cursors(ids, from: from, to: to, state_dir: state_dir)
110
+ ids.each { |id| Session.reparent(id, to: to, from: from, state_dir: state_dir) }
111
+ move_rings(ids, from: from, to: to, state_dir: state_dir)
112
+ ids.each { |id| note_child(id, to: to, undo: undo, state_dir: state_dir) }
113
+ clear_intent(to, state_dir: state_dir)
114
+ Log.info(:worker, undo ? "delegates_moved_back" : "delegates_moved", from: from[0, 8], to: to[0, 8], count: ids.size)
115
+ end
116
+
117
+ # The rings in +from+'s children/ from children whose parent is +to+
118
+ # now (a failed continue's link, before it is deleted: a child that
119
+ # read the override just before the undo rang it).
120
+ def return_rings(from:, to:, state_dir:)
121
+ ids = SessionInbox.find_ring_files(Session.session_dir(from, state_dir: state_dir)).filter_map do |file|
122
+ id = SessionInbox.read_ring(file)&.dig(:child_id)
123
+ id if Session.valid_id?(id) && Session.parent_override(id, state_dir: state_dir) == to
124
+ end
125
+ move_rings(ids.uniq, from: from, to: to, state_dir: state_dir)
126
+ end
127
+
128
+ # A worker starting for +session_id+ finishes a move its continue left
129
+ # half done (the intent file is still there): only those children, never
130
+ # the predecessor's others.
131
+ # @return [Array<String>] the ids moved, [] with no intent
132
+ def adopt(session_id, state_dir:)
133
+ intent = read_intent(session_id, state_dir: state_dir) or return []
134
+ apply(intent.ids, from: intent.from, to: session_id, state_dir: state_dir)
135
+ intent.ids
136
+ rescue StandardError => e
137
+ Log.warn(:worker, "delegates_adopt_failed", sid: session_id[0, 8], error: e.class.name, msg: e.message)
138
+ []
139
+ end
140
+
141
+ # @return [Intent, nil]
142
+ def read_intent(session_id, state_dir:)
143
+ data = JSON.parse(File.read(File.join(Session.session_dir(session_id, state_dir: state_dir), INTENT_FILE)))
144
+ return nil unless data.is_a?(Hash) && Session.valid_id?(data["from"]) && data["ids"].is_a?(Array)
145
+
146
+ Intent.new(from: data["from"], ids: data["ids"].select { |id| Session.valid_id?(id) })
147
+ rescue JSON::ParserError, SystemCallError
148
+ nil
149
+ end
150
+
151
+ def write_intent(session_id, intent, state_dir:)
152
+ dir = Session.session_dir(session_id, state_dir: state_dir)
153
+ FileUtils.mkdir_p(dir)
154
+ AtomicFile.write(File.join(dir, INTENT_FILE), JSON.generate({ "from" => intent.from, "ids" => intent.ids }))
155
+ end
156
+
157
+ def clear_intent(session_id, state_dir:)
158
+ FileUtils.rm_f(File.join(Session.session_dir(session_id, state_dir: state_dir), INTENT_FILE))
159
+ end
160
+
161
+ def copy_cursors(ids, from:, to:, state_dir:)
162
+ cursors = Tools::DelegateCursors.read(from, state_dir: state_dir).slice(*ids)
163
+ Tools::DelegateCursors.merge(to, cursors, state_dir: state_dir)
164
+ end
165
+
166
+ # A ring keeps its file name (its time orders the rings).
167
+ def move_rings(ids, from:, to:, state_dir:)
168
+ rings = SessionInbox.find_ring_files(Session.session_dir(from, state_dir: state_dir)).select do |file|
169
+ ids.include?(SessionInbox.read_ring(file)&.dig(:child_id))
170
+ end
171
+ return if rings.empty?
172
+
173
+ dir = File.join(Session.session_dir(to, state_dir: state_dir), SessionInbox::CHILDREN_DIR)
174
+ FileUtils.mkdir_p(dir)
175
+ rings.each do |file|
176
+ File.rename(file, File.join(dir, File.basename(file)))
177
+ rescue Errno::ENOENT
178
+ nil # taken meanwhile
179
+ end
180
+ end
181
+
182
+ # The child's system prompt names the parent it started with for its
183
+ # whole life (a prompt-cache miss each time it changed): a note tells a
184
+ # running one where its notes and reports go now.
185
+ def note_child(child_id, to:, state_dir:, undo: false)
186
+ text = if undo
187
+ "Your parent session is #{to[0, 8]} (#{to}) again: the continue that moved you to its next link " \
188
+ "failed. Send notes there, and your reports reach it."
189
+ else
190
+ "Your parent session is now #{to[0, 8]} (#{to}), the next link of its session chain: send notes " \
191
+ "there, and your reports reach it."
192
+ end
193
+ SessionInbox.write_note(child_id, text: text, source: NOTE_SOURCE, state_dir: state_dir)
194
+ end
195
+
196
+ # Hold off a worker for the continue's new link (#starting?) until its
197
+ # own worker holds it: written before the move, removed by that worker
198
+ # once it has the owner lock (Worker#start).
199
+ def mark_starting(session_id, state_dir:)
200
+ dir = Session.session_dir(session_id, state_dir: state_dir)
201
+ FileUtils.mkdir_p(dir)
202
+ AtomicFile.write(File.join(dir, STARTING_FILE), JSON.generate({ "pid" => Process.pid, "at" => Time.now.iso8601(3) }))
203
+ end
204
+
205
+ def clear_starting(session_id, state_dir:)
206
+ FileUtils.rm_f(File.join(Session.session_dir(session_id, state_dir: state_dir), STARTING_FILE))
207
+ end
208
+
209
+ # Whether a continue is moving delegates to +session_id+ right now: its
210
+ # marker is there, fresh, and the process that wrote it runs.
211
+ def starting?(session_id, state_dir:)
212
+ file = File.join(Session.session_dir(session_id, state_dir: state_dir), STARTING_FILE)
213
+ return false unless File.exist?(file)
214
+
215
+ data = JSON.parse(File.read(file))
216
+ return false if Time.now - Time.iso8601(data["at"].to_s) > STARTING_STALE
217
+
218
+ process_alive?(data["pid"].to_i)
219
+ rescue JSON::ParserError, ArgumentError, SystemCallError
220
+ false
221
+ end
222
+
223
+ # Signal 0 only asks whether the process exists.
224
+ def process_alive?(pid)
225
+ return false unless pid.positive?
226
+
227
+ Process.kill(0, pid)
228
+ true
229
+ rescue Errno::ESRCH
230
+ false
231
+ rescue Errno::EPERM
232
+ true
233
+ end
234
+ end
235
+ end
@@ -57,16 +57,21 @@ module Samagotchi
57
57
  # @param wake [#call, nil] (parent id) wakes a parent with no worker;
58
58
  # nil: SessionManager.wake_for_report on a thread
59
59
  # @return [String, nil] the ring file, nil when none was written
60
+ # The parent is read fresh: a continue may have moved the child to the
61
+ # chain's new link (Session::PARENT_FILE) since its worker loaded it.
60
62
  def ring(child, why:, state_dir:, wake: nil)
61
63
  return nil if mode == "off"
62
- return nil unless child&.delegate? && child.parent_id
64
+ return nil unless child&.delegate?
65
+
66
+ parent_id = Session.parent_override(child.id, state_dir: state_dir) || child.parent_id
67
+ return nil unless parent_id
63
68
  # A deleted parent gets no orphan dir.
64
- return nil unless Session.exist?(child.parent_id, state_dir: state_dir)
69
+ return nil unless Session.exist?(parent_id, state_dir: state_dir)
65
70
 
66
- parent_dir = Session.session_dir(child.parent_id, state_dir: state_dir)
71
+ parent_dir = Session.session_dir(parent_id, state_dir: state_dir)
67
72
  path = SessionInbox.write_ring(parent_dir, child_id: child.id, why: why)
68
- Log.info(:worker, "delegate_rang", parent: child.parent_id[0, 8], why: why)
69
- wake_parent(child.parent_id, state_dir: state_dir, wake: wake) if mode == "wake"
73
+ Log.info(:worker, "delegate_rang", parent: parent_id[0, 8], why: why)
74
+ wake_parent(parent_id, state_dir: state_dir, wake: wake) if mode == "wake"
70
75
  path
71
76
  rescue StandardError => e
72
77
  Log.warn(:worker, "delegate_ring_failed", parent: child&.parent_id.to_s[0, 8], error: e.class.name, msg: e.message)
@@ -129,10 +134,14 @@ module Samagotchi
129
134
  def origin = { client_id: "#{ClientId::CHILD_PREFIX}#{child_id[0, 8]}" }
130
135
  end
131
136
 
132
- def initialize(session_id:, state_dir:)
137
+ # @param predecessor [String, nil] the link this session continues
138
+ # (Session#continues): a child a continue moved here may have rung it
139
+ # just as it moved (#ring_files)
140
+ def initialize(session_id:, state_dir:, predecessor: nil)
133
141
  @session_id = session_id
134
142
  @state_dir = state_dir
135
143
  @session_dir = Session.session_dir(session_id, state_dir: state_dir)
144
+ @predecessor_dir = Session.valid_id?(predecessor) ? Session.session_dir(predecessor, state_dir: state_dir) : nil
136
145
  # child id → the Report this turn took for it (rings merged).
137
146
  @taken = {}
138
147
  end
@@ -142,7 +151,7 @@ module Samagotchi
142
151
  return false if ChildRing.mode == "off"
143
152
 
144
153
  taken = taken_rings
145
- SessionInbox.find_ring_files(@session_dir).any? { |file| !taken.include?(file) }
154
+ ring_files.any? { |file| !taken.include?(file) }
146
155
  end
147
156
 
148
157
  # The reports for rings this turn hasn't taken yet, recorded as taken.
@@ -153,7 +162,7 @@ module Samagotchi
153
162
  return [] if ChildRing.mode == "off"
154
163
 
155
164
  taken = taken_rings
156
- fresh = SessionInbox.find_ring_files(@session_dir).reject { |file| taken.include?(file) }
165
+ fresh = ring_files.reject { |file| taken.include?(file) }
157
166
  by_child = fresh.group_by { |file| SessionInbox.read_ring(file)&.dig(:child_id) }
158
167
  by_child.filter_map do |child_id, rings|
159
168
  report = child_id && report_for(child_id, rings)
@@ -193,6 +202,21 @@ module Samagotchi
193
202
  @taken.values.flat_map(&:rings)
194
203
  end
195
204
 
205
+ # The rings in children/, and the predecessor's rings from children a
206
+ # continue moved here (their parent.json names this session): a ring
207
+ # its child wrote as the move went on. Another child's ring there is
208
+ # the predecessor's, left alone.
209
+ def ring_files
210
+ own = SessionInbox.find_ring_files(@session_dir)
211
+ return own unless @predecessor_dir
212
+
213
+ strays = SessionInbox.find_ring_files(@predecessor_dir).select do |file|
214
+ child_id = SessionInbox.read_ring(file)&.dig(:child_id)
215
+ Session.valid_id?(child_id) && Session.parent_override(child_id, state_dir: @state_dir) == @session_id
216
+ end
217
+ own + strays
218
+ end
219
+
196
220
  # A second ring for a child taken earlier in this turn: read from where
197
221
  # the first report left the cursor, keeping the first one's +before+.
198
222
  def merge_taken(earlier, report)
@@ -333,7 +333,12 @@ module Samagotchi
333
333
  false
334
334
  end
335
335
 
336
- def continued_words(session) = session.continues ? " (continues #{session.continues[0, 8]})" : ""
336
+ def continued_words(session)
337
+ return "" unless session.continues
338
+
339
+ moved = Array(session.moved_children).size
340
+ " (continues #{session.continues[0, 8]}#{"; #{moved} delegate#{"s" if moved != 1} moved" if moved.positive?})"
341
+ end
337
342
 
338
343
  # One existing session, then its next reply. The cursor and baseline
339
344
  # are taken before the message goes in, so neither an older reply nor a
@@ -32,6 +32,12 @@ module Samagotchi
32
32
  # resume removes it. A file from before the marker says "stopped" in
33
33
  # its status field, which reads the same.
34
34
  STOPPED_FILE = "stopped"
35
+ # A moved session's parent, <session dir>/parent.json ({parent_id, from,
36
+ # at}): a continue moving a delegate to the chain's new link
37
+ # (ChildMove) writes it instead of rewriting the session file its live
38
+ # worker may save a stale copy of; both load paths apply it, like the
39
+ # stop marker. Never removed: a later move overwrites it.
40
+ PARENT_FILE = "parent.json"
35
41
  # The memory a delegate child starts with (Tools::Delegate::CHILD_MEMORIES).
36
42
  DELEGATE_MEMORY = "system/delegated"
37
43
 
@@ -130,6 +136,10 @@ module Samagotchi
130
136
  # not saved.
131
137
  attr_accessor :model_warning
132
138
 
139
+ # The delegates a continue moved to this new link (ChildMove;
140
+ # SessionManager.continue_session sets it); not saved.
141
+ attr_accessor :moved_children
142
+
133
143
  def initialize(id:, mode:, model_name:, working_directory:, messages:, created_at:, updated_at:,
134
144
  metadata_version: METADATA_VERSION, status: STATUS_IDLE, last_prompt: "",
135
145
  first_preview: "", test_run: false, pending_question: nil,
@@ -258,6 +268,7 @@ module Samagotchi
258
268
 
259
269
  session = from_h(JSON.parse(File.read(path)))
260
270
  session.status = STATUS_STOPPED if stopped_marker?(session.id, state_dir: state_dir)
271
+ session.parent_id = parent_override(session.id, state_dir: state_dir) || session.parent_id
261
272
  session
262
273
  rescue JSON::ParserError => e
263
274
  raise ArgumentError, "Session file corrupted (#{session_id}): #{e.message}"
@@ -386,6 +397,7 @@ module Samagotchi
386
397
 
387
398
  session = from_h(data, messages: false)
388
399
  session.status = STATUS_STOPPED if stopped_marker?(session.id, state_dir: File.dirname(path))
400
+ session.parent_id = parent_override(session.id, state_dir: File.dirname(path)) || session.parent_id
389
401
  session
390
402
  rescue JSON::ParserError, KeyError, SystemCallError
391
403
  nil
@@ -453,6 +465,29 @@ module Samagotchi
453
465
  File.exist?(File.join(session_dir(session_id, state_dir: state_dir), STOPPED_FILE))
454
466
  end
455
467
 
468
+ # Give +session_id+ a new parent (PARENT_FILE), +from+ the one it had.
469
+ def self.reparent(session_id, to:, from:, state_dir: default_state_dir)
470
+ dir = session_dir(session_id, state_dir: state_dir)
471
+ FileUtils.mkdir_p(dir)
472
+ AtomicFile.write(File.join(dir, PARENT_FILE),
473
+ JSON.generate({ "parent_id" => check_id!(to), "from" => from, "at" => Time.now.iso8601(3) }))
474
+ end
475
+
476
+ # The parent PARENT_FILE gives the session, nil without one (or with
477
+ # one that doesn't hold an id).
478
+ # @return [String, nil]
479
+ def self.parent_override(session_id, state_dir: default_state_dir)
480
+ file = File.join(session_dir(session_id, state_dir: state_dir), PARENT_FILE)
481
+ # The lists read every session: most have none, so no exception each.
482
+ return nil unless File.exist?(file)
483
+
484
+ data = JSON.parse(File.read(file))
485
+ id = data.is_a?(Hash) && data["parent_id"]
486
+ valid_id?(id) ? id : nil
487
+ rescue JSON::ParserError, SystemCallError
488
+ nil
489
+ end
490
+
456
491
  # An id that is not a session id: it could name a path outside the
457
492
  # sessions dir ("../x", "/etc", "a/b", a NUL), or is empty.
458
493
  class InvalidId < ArgumentError; end
@@ -31,6 +31,7 @@ require_relative "image_store"
31
31
  require_relative "plugin_session_state"
32
32
  require_relative "context_sources"
33
33
  require_relative "session_chain"
34
+ require_relative "child_move"
34
35
  require_relative "terminal_ui"
35
36
 
36
37
  module Samagotchi
@@ -118,8 +119,9 @@ module Samagotchi
118
119
 
119
120
  # A continue that can't happen (#continue_session). reason: :continued
120
121
  # (another session continues it already: +ids+ names it), :open_children
121
- # (children still running, waiting, live or with a reply it wasn't given:
122
- # +ids+ names them) or :folder_gone (its folder isn't there).
122
+ # (open children it can't move to the new link: on an older chi's
123
+ # worker or open in a chi REPL; +ids+ names them) or :folder_gone (its
124
+ # folder isn't there).
123
125
  class ContinueRefused < StandardError
124
126
  attr_reader :session_id, :reason, :ids
125
127
 
@@ -137,7 +139,8 @@ module Samagotchi
137
139
  case @reason
138
140
  when :continued then "#{short} is continued already, by #{@ids.first[0, 8]}; continue that one (or last:#{short})"
139
141
  when :open_children
140
- "#{short} has delegates still open: #{detail}; wait for them, stop them or archive them, then continue"
142
+ "#{short} has delegates still open that can't move to the next link: #{detail}; " \
143
+ "then continue (or wait for them, stop them or archive them)"
141
144
  else "#{short}'s folder #{detail} is gone"
142
145
  end
143
146
  end
@@ -166,12 +169,14 @@ module Samagotchi
166
169
  # @param note [String, nil] a context note the session starts with,
167
170
  # from +note_source+: queued before its worker spawns, so its first
168
171
  # turn sees it
172
+ # @param before_spawn [#call, nil] (session) runs once the session and
173
+ # its note are saved, before its worker spawns
169
174
  # @return [Session] with #seed_images_dropped: refs whose file was gone,
170
175
  # and #model_warning: an id its host's saved list doesn't have
171
176
  def self.spawn_session(prompt:, mode: "assist", working_directory: nil, model_name: nil, state_dir: nil,
172
177
  memories: [], muted_memories: [], parent_id: nil, messages: [], images_from: nil,
173
178
  title: nil, delegate: false, llm_context: nil, continues: nil, note: nil,
174
- note_source: "chi")
179
+ note_source: "chi", before_spawn: nil)
175
180
  sd = state_dir || Session.default_state_dir
176
181
  # The resolved ref is stored (a resumed session keeps its model when an
177
182
  # alias is retargeted), with the name as typed beside it. A wrong host
@@ -210,6 +215,7 @@ module Samagotchi
210
215
  end
211
216
  setup_session_directory(session_dir, session, state_dir: sd)
212
217
  SessionInbox.write_note(session.id, text: note, source: note_source, state_dir: sd) if note
218
+ before_spawn&.call(session)
213
219
  spawn_worker_for_session(session, state_dir: sd)
214
220
  session
215
221
  end
@@ -228,10 +234,13 @@ module Samagotchi
228
234
  # it was typed as, so an alias holds) and with its own llm_context,
229
235
  # starting with a context note from chi: the link and the previous
230
236
  # link's recap (SessionChain.note_text). The previous link is archived
231
- # first, with its delegates (archive_session's rules: a turn running,
232
- # a prompt queued or the step-limit question refuse; an idle worker is
233
- # stopped), so a refusal starts nothing. Before that its live worker is
234
- # asked for a recap and given up to +recap_wait+ seconds to write it.
237
+ # first, with its finished delegates (archive_session's rules: a turn
238
+ # running, a prompt queued or the step-limit question refuse; an idle
239
+ # worker is stopped), so a refusal starts nothing. Its open delegates
240
+ # (running, waiting, live, an unreported reply, at any depth below them)
241
+ # move to the new link instead (ChildMove): their reports reach it.
242
+ # Before that its live worker is asked for a recap and given up to
243
+ # +recap_wait+ seconds to write it.
235
244
  # One continue of a session at a time (a lock in its folder): a double
236
245
  # click finds the first one's link and is refused; a last:<id> one
237
246
  # follows the chain to its new end instead.
@@ -239,9 +248,11 @@ module Samagotchi
239
248
  # @param prompt [String, nil] its first message (nil: it starts idle)
240
249
  # @param title [String, nil] what the lists show before the first turn;
241
250
  # by default the prompt, else the previous link's preview
242
- # @return [Session] the new link (#model_warning as spawn_session's)
251
+ # @return [Session] the new link (#model_warning as spawn_session's,
252
+ # #moved_children: the delegates it took over)
243
253
  # @raise [ArgumentError] no such session (Session::AmbiguousId for a prefix of several)
244
- # @raise [ContinueRefused] continued already, delegates still open, or its folder is gone
254
+ # @raise [ContinueRefused] continued already, delegates open that can't
255
+ # move, or its folder is gone
245
256
  # @raise [ArchiveRefused, OwnedByTUI] as archive_session
246
257
  def self.continue_session(id_or_ref, prompt: nil, title: nil, state_dir: nil, recap_wait: CONTINUE_RECAP_WAIT,
247
258
  archive_wait: 5)
@@ -270,82 +281,135 @@ module Samagotchi
270
281
 
271
282
  private_class_method def self.continue_locked(id, prompt:, title:, state_dir:, recap_wait:, archive_wait:)
272
283
  previous = Session.load(id, state_dir: state_dir)
273
- check_continue!(previous, state_dir)
284
+ plan = check_continue!(previous, state_dir)
274
285
  # archive_session's refusals that can be known now, before the wait.
275
- archivable!(id, state_dir)
286
+ archivable!(id, state_dir, except: plan.move)
276
287
  recap_before_archive(id, state_dir, wait: recap_wait)
277
- # Again: a turn sent to it during the wait may have delegated.
278
- check_open_children!(id, state_dir)
279
- already = [id, *descendant_ids(id, state_dir)].select do |sid|
288
+ # Again: a turn sent to it during the wait may have delegated (they
289
+ # move too), or a delegate finished.
290
+ plan = move_plan!(id, state_dir)
291
+ already = [id, *descendant_ids(id, state_dir, except: plan.move)].select do |sid|
280
292
  ArchiveStore.archived?(Session.session_dir(sid, state_dir: state_dir))
281
293
  end
282
- archived = archive_session(id, state_dir: state_dir, wait: archive_wait)[:archived] - already
294
+ archived = archive_session(id, state_dir: state_dir, wait: archive_wait, except: plan.move)[:archived] - already
283
295
  begin
284
- spawn_continuation(previous, prompt: prompt, title: title, state_dir: state_dir)
296
+ spawn_continuation(previous, prompt: prompt, title: title, state_dir: state_dir, move: plan.move)
285
297
  rescue StandardError
286
- # Nothing started: what this archived goes back to the lists, and
287
- # a new link saved before its worker failed to spawn goes (left,
288
- # it would hold the chain: "continued already", last: to it).
289
- discard_failed_link(id, state_dir)
290
- archived.each { |sid| ArchiveStore.unarchive(sid, state_dir: state_dir) }
298
+ # Nothing started: the delegates go back, what this archived goes
299
+ # back to the lists, and a new link saved before its worker failed
300
+ # to spawn goes (left, it would hold the chain: "continued already",
301
+ # last: to it).
302
+ undo_move(id, state_dir, moved: plan.move)
303
+ begin
304
+ discard_failed_link(id, state_dir)
305
+ ensure
306
+ archived.each { |sid| ArchiveStore.unarchive(sid, state_dir: state_dir) }
307
+ wake_for_rings(id, state_dir) unless plan.move.empty?
308
+ end
291
309
  raise
292
310
  end
293
311
  end
294
312
 
313
+ # The delegates a failed #continue_session moved, or began to (its
314
+ # intent names them), go back to +id+.
315
+ private_class_method def self.undo_move(id, state_dir, moved:)
316
+ link = SessionChain.next_of(id, state_dir: state_dir) or return
317
+ ids = ChildMove.read_intent(link, state_dir: state_dir)&.ids || moved
318
+ ChildMove.apply(ids, from: link, to: id, state_dir: state_dir, undo: true)
319
+ ChildMove.clear_intent(link, state_dir: state_dir)
320
+ rescue StandardError => e
321
+ Log.warn(:worker, "continue_undo_move_failed", sid: id, error: e.class.name, msg: e.message)
322
+ end
323
+
295
324
  # The link a failed #continue_session saved (no other continues +id+:
296
- # check_continue! made sure under the lock).
325
+ # check_continue! made sure under the lock): its worker stopped if one
326
+ # started, then the rings a moved delegate wrote into it just before
327
+ # the undo go back to +id+, then the link goes. A worker that outlives
328
+ # the stop is raised (DeleteRefused): the link stays, and the caller
329
+ # hears it.
297
330
  private_class_method def self.discard_failed_link(id, state_dir)
298
331
  orphan = SessionChain.next_of(id, state_dir: state_dir) or return
332
+ if refuse_tui!(orphan, state_dir: state_dir) && !stop_session(orphan, state_dir: state_dir, wait: 10)
333
+ raise DeleteRefused.new(orphan, :still_stopping)
334
+ end
335
+
336
+ ChildMove.return_rings(from: orphan, to: id, state_dir: state_dir)
299
337
  delete_session(orphan, state_dir: state_dir)
338
+ rescue DeleteRefused
339
+ raise
300
340
  rescue StandardError => e
301
341
  Log.warn(:worker, "continue_cleanup_failed", sid: id, error: e.class.name, msg: e.message)
302
342
  end
303
343
 
304
- private_class_method def self.spawn_continuation(previous, prompt:, title:, state_dir:)
344
+ # A failed continue's previous link, back in the lists, with rings that
345
+ # came in meanwhile: wake it for them, as the ring's own wake would have.
346
+ private_class_method def self.wake_for_rings(id, state_dir)
347
+ return if SessionInbox.find_ring_files(Session.session_dir(id, state_dir: state_dir)).empty?
348
+
349
+ wake_for_report(id, state_dir: state_dir)
350
+ rescue StandardError => e
351
+ Log.warn(:worker, "continue_wake_failed", sid: id, error: e.class.name, msg: e.message)
352
+ end
353
+
354
+ # The new link, holding off any worker but its own (ChildMove.starting?)
355
+ # while +move+ goes to it.
356
+ private_class_method def self.spawn_continuation(previous, prompt:, title:, state_dir:, move:)
305
357
  recap = RecapStore.read(Session.session_dir(previous.id, state_dir: state_dir))
306
358
  start = prompt.to_s.strip.empty? ? nil : prompt
307
359
  title = previous.first_preview if title.to_s.strip.empty? && start.nil?
308
- spawn_session(prompt: start, title: title, working_directory: previous.working_directory,
309
- model_name: previous.model_typed || previous.model_name, llm_context: previous.llm_context,
310
- continues: previous.id, note: SessionChain.note_text(previous, recap: recap),
311
- note_source: SessionChain::NOTE_SOURCE, state_dir: state_dir)
360
+ before_spawn = lambda do |link|
361
+ ChildMove.mark_starting(link.id, state_dir: state_dir)
362
+ ChildMove.apply(move, from: previous.id, to: link.id, state_dir: state_dir)
363
+ end
364
+ link = spawn_session(prompt: start, title: title, working_directory: previous.working_directory,
365
+ model_name: previous.model_typed || previous.model_name, llm_context: previous.llm_context,
366
+ continues: previous.id, note: SessionChain.note_text(previous, recap: recap),
367
+ note_source: SessionChain::NOTE_SOURCE, state_dir: state_dir, before_spawn: before_spawn)
368
+ link.moved_children = move
369
+ link
312
370
  end
313
371
 
314
372
  # The refusals that come before anything changes: continued already (no
315
- # forks in a chain), a delegate still open (its report would go to the
316
- # archived link), the folder gone (the worker would run elsewhere), a
373
+ # forks in a chain), an open delegate that can't move to the new link
374
+ # (ChildMove.plan), the folder gone (the worker would run elsewhere), a
317
375
  # model whose host config.yml no longer has.
376
+ # @return [ChildMove::Plan]
318
377
  # @raise [ContinueRefused, ModelProfile::MissingModel]
319
378
  private_class_method def self.check_continue!(previous, state_dir)
320
379
  if (following = SessionChain.next_of(previous.id, state_dir: state_dir))
321
380
  raise ContinueRefused.new(previous.id, :continued, ids: [following])
322
381
  end
323
382
 
324
- check_open_children!(previous.id, state_dir)
383
+ plan = move_plan!(previous.id, state_dir)
325
384
  # Its model's host, as spawn_session checks it: before the archive
326
385
  # stops the previous link's worker.
327
386
  ModelProfile.check_host!(ModelProfile.required_model_name(previous.model_typed || previous.model_name))
328
387
  dir = previous.working_directory.to_s
329
388
  raise ContinueRefused.new(previous.id, :folder_gone, detail: dir) unless File.directory?(dir)
389
+
390
+ plan
330
391
  end
331
392
 
332
- # A child the next link couldn't follow once its parent is archived.
393
+ # A child still open below a session (#open_children); why: running,
394
+ # waiting, live, unreported reply, or why it can't move (ChildMove).
333
395
  OpenChild = Data.define(:id, :short_id, :why)
334
396
 
335
- # @raise [ContinueRefused] while #open_children has any
336
- private_class_method def self.check_open_children!(id, state_dir)
337
- open = open_children(id, state_dir)
338
- return if open.empty?
397
+ # The delegates a continue of +id+ moves (ChildMove.plan).
398
+ # @return [ChildMove::Plan]
399
+ # @raise [ContinueRefused] for open ones it can't move
400
+ private_class_method def self.move_plan!(id, state_dir)
401
+ plan = ChildMove.plan(id, open: open_children(id, state_dir), state_dir: state_dir)
402
+ return plan if plan.refuse.empty?
339
403
 
340
- raise ContinueRefused.new(id, :open_children, ids: open.map(&:id),
341
- detail: open.map { |c| "#{c.short_id} (#{c.why})" }.join(", "))
404
+ raise ContinueRefused.new(id, :open_children, ids: plan.refuse.map(&:id),
405
+ detail: plan.refuse.map { |c| "#{c.short_id} (#{c.why})" }.join(", "))
342
406
  end
343
407
 
344
408
  # The unarchived children, at any depth (the archive's cascade), of the
345
409
  # session still running, waiting for an answer, with a live worker, or
346
410
  # (a delegate) with a reply its parent wasn't given (ChildrenStatus).
347
411
  # @return [Array<OpenChild>]
348
- private_class_method def self.open_children(id, state_dir)
412
+ def self.open_children(id, state_dir)
349
413
  require_relative "children_status"
350
414
  [id, *descendant_ids(id, state_dir)].flat_map { |node| ChildrenStatus.of(node, state_dir: state_dir) }.uniq(&:id)
351
415
  .filter_map do |child|
@@ -557,10 +621,12 @@ module Samagotchi
557
621
  # @raise [ArchiveRefused] a turn runs in it or in a child, a prompt is
558
622
  # queued in one (a later resume would run it), a live one offers to
559
623
  # continue past the step limit, or it is a scratch session
560
- def self.archive_session(id_or_prefix, state_dir: nil, wait: 5)
624
+ # @param except [Array<String>] descendants left alone with their own
625
+ # subtrees (delegates a continue moves to the chain's new link)
626
+ def self.archive_session(id_or_prefix, state_dir: nil, wait: 5, except: [])
561
627
  sd = state_dir || Session.default_state_dir
562
628
  id = archive_target(id_or_prefix, sd)
563
- tree, owners = archivable!(id, sd)
629
+ tree, owners = archivable!(id, sd, except: except)
564
630
  stopped = tree.select { |sid| owners[sid] }
565
631
  stopped.each { |sid| stop_session(sid, state_dir: sd, wait: wait) }
566
632
  archived, discarded = tree.partition { |sid| ArchiveStore.archive(sid, state_dir: sd) }
@@ -571,10 +637,10 @@ module Samagotchi
571
637
  # archived now (#archive_refusal for each), else the refusal.
572
638
  # @return [Array(Array<String>, Hash)] the ids, and id => OwnerLock::Owner or nil
573
639
  # @raise [ArchiveRefused, OwnedByTUI]
574
- private_class_method def self.archivable!(id, state_dir)
640
+ private_class_method def self.archivable!(id, state_dir, except: [])
575
641
  raise ArchiveRefused.new(id, :scratch) if Session.load(id, state_dir: state_dir).scratch
576
642
 
577
- tree = [id, *descendant_ids(id, state_dir)]
643
+ tree = [id, *descendant_ids(id, state_dir, except: except)]
578
644
  owners = tree.to_h { |sid| [sid, session_owner(sid, state_dir: state_dir)] }
579
645
  tree.each do |sid|
580
646
  refusal = archive_refusal(sid, owners[sid], state_dir)
@@ -620,13 +686,13 @@ module Samagotchi
620
686
  end
621
687
 
622
688
  # Children, their children, …: a delegated session doesn't delegate
623
- # further today, a plugin's fork may.
624
- private_class_method def self.descendant_ids(id, state_dir)
689
+ # further today, a plugin's fork may. The walk doesn't enter +except+.
690
+ def self.descendant_ids(id, state_dir, except: [])
625
691
  seen = [id]
626
692
  queue = [id]
627
693
  until queue.empty?
628
694
  children_of(queue.shift, state_dir: state_dir).each do |child|
629
- next if seen.include?(child[:id])
695
+ next if seen.include?(child[:id]) || except.include?(child[:id])
630
696
 
631
697
  seen << child[:id]
632
698
  queue << child[:id]
@@ -695,6 +761,9 @@ module Samagotchi
695
761
  # One this process just spawned may not hold the lock yet (a slow
696
762
  # start, deliver_turn's second resume): it would only lose it.
697
763
  return session if still_starting?(session.id, state_dir: sd)
764
+ # A continue moving delegates to it spawns its worker once they are
765
+ # moved; one spawned now (a moved child's ring) would race the move.
766
+ return session if ChildMove.starting?(session.id, state_dir: sd)
698
767
 
699
768
  Session.clear_stopped(session.id, state_dir: sd)
700
769
  session.status = Session::STATUS_IDLE
@@ -705,18 +774,27 @@ module Samagotchi
705
774
 
706
775
  # A delegate child rang a parent that has no worker (ChildRing): start
707
776
  # one, which runs a turn for the report. Not for a parent that is gone,
708
- # stopped, archived, a scratch session, or open in a chi REPL: the ring
709
- # waits on disk for whoever runs it next (#resume_session itself would
710
- # clear the stop). A stop landing between the check and the resume is
711
- # a small window, left as is.
777
+ # stopped, archived, a scratch session, open in a chi REPL, or a
778
+ # continue's new link still taking its delegates (its own spawn comes):
779
+ # the ring waits on disk for whoever runs it next (#resume_session
780
+ # itself would clear the stop). A stop landing between the check and
781
+ # the resume is a small window, left as is. An archived parent with a
782
+ # later link in its chain wakes that link instead: a ring a moved child
783
+ # wrote just as the continue moved it reaches the new link that way
784
+ # (ChildReports reads its predecessor's rings).
712
785
  # @return [Symbol] :woken, or why not (:gone, :stopped, :archived,
713
- # :scratch, :owned)
714
- def self.wake_for_report(session_id, state_dir: nil)
786
+ # :scratch, :owned, :starting)
787
+ def self.wake_for_report(session_id, state_dir: nil, forward: true)
715
788
  sd = state_dir || Session.default_state_dir
716
789
  return :gone unless Session.exist?(session_id, state_dir: sd)
717
790
  return :owned if session_owner(session_id, state_dir: sd)
718
791
  return :stopped if Session.stopped_marker?(session_id, state_dir: sd)
719
- return :archived if ArchiveStore.archived?(Session.session_dir(session_id, state_dir: sd))
792
+
793
+ if ArchiveStore.archived?(Session.session_dir(session_id, state_dir: sd))
794
+ following = forward && SessionChain.next_of(session_id, state_dir: sd)
795
+ return following ? wake_for_report(following, state_dir: sd, forward: false) : :archived
796
+ end
797
+ return :starting if ChildMove.starting?(session_id, state_dir: sd)
720
798
 
721
799
  session = Session.load(session_id, state_dir: sd)
722
800
  return :stopped if session.status == Session::STATUS_STOPPED
@@ -33,10 +33,13 @@ module Samagotchi
33
33
  # The parent's cursors, one per child, in <parent dir>/delegates.json, so
34
34
  # a respawned worker (or a REPL resuming the session) doesn't hand the
35
35
  # model a reply it already had. Written by whoever owns the parent (its
36
- # worker, or the REPL its kernel runs in), so a Mutex is enough for its
37
- # turn thread vs its loop thread; the file is replaced whole.
36
+ # worker, or the REPL its kernel runs in), and by a continue moving
37
+ # delegates to the chain's new link (ChildMove, from another process):
38
+ # a Mutex for the owner's turn thread vs its loop thread, and a flock on
39
+ # LOCK_FILE between processes; the file is replaced whole.
38
40
  module DelegateCursors
39
41
  FILE = "delegates.json"
42
+ LOCK_FILE = "delegates.lock"
40
43
  LOCK = Mutex.new
41
44
 
42
45
  module_function
@@ -51,7 +54,7 @@ module Samagotchi
51
54
  # @yieldreturn [DelegateCursor]
52
55
  # @return [DelegateCursor] the new cursor
53
56
  def update(parent_id, child_id, state_dir:)
54
- LOCK.synchronize do
57
+ locked(parent_id, state_dir: state_dir) do
55
58
  all = read(parent_id, state_dir: state_dir)
56
59
  cursor = yield get_from(all, child_id)
57
60
  all[child_id] = cursor.to_json_hash
@@ -60,6 +63,31 @@ module Samagotchi
60
63
  end
61
64
  end
62
65
 
66
+ # Add the cursors of +entries+ (child id → cursor fields, as #read
67
+ # gives them) that the parent has none for; one it has is kept.
68
+ # @return [Array<String>] the child ids added
69
+ def merge(parent_id, entries, state_dir:)
70
+ return [] if entries.empty?
71
+
72
+ locked(parent_id, state_dir: state_dir) do
73
+ all = read(parent_id, state_dir: state_dir)
74
+ added = entries.keys - all.keys
75
+ write(parent_id, all.merge(entries.slice(*added)), state_dir: state_dir) unless added.empty?
76
+ added
77
+ end
78
+ end
79
+
80
+ def locked(parent_id, state_dir:)
81
+ LOCK.synchronize do
82
+ dir = Session.session_dir(parent_id, state_dir: state_dir)
83
+ FileUtils.mkdir_p(dir)
84
+ File.open(File.join(dir, LOCK_FILE), File::RDWR | File::CREAT, 0o600) do |lock|
85
+ lock.flock(File::LOCK_EX)
86
+ yield
87
+ end
88
+ end
89
+ end
90
+
63
91
  def get_from(all, child_id)
64
92
  data = all[child_id]
65
93
  return DelegateCursor.empty unless data.is_a?(Hash)
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.47.0"
4
+ VERSION = "0.48.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -655,9 +655,11 @@ module Samagotchi
655
655
  return error if error
656
656
 
657
657
  port = await_bridge_port(session.id)
658
- [session.continues, session.id].each { |sid| @hub&.touch(sid) }
658
+ moved = session.moved_children || []
659
+ # The moved delegates' cards fold under the new link now.
660
+ [session.continues, session.id, *moved].each { |sid| @hub&.touch(sid) }
659
661
  record_history(body, prompt) if !idle && PromptHistory.shell_line?(prompt.to_s.strip)
660
- json_response(201, session_to_json(session).merge(bridge_port: port))
662
+ json_response(201, session_to_json(session).merge(bridge_port: port, moved: moved))
661
663
  end
662
664
 
663
665
  # SessionManager.continue_session, its refusals as responses: [session,
@@ -37,7 +37,7 @@ import { createTurnView, turnHistoryHtml } from "./turn_view.js";
37
37
  import { BADGES, emptyAnswerHtml, htmlElement, stateBadgeHtml, userBubbleHtml } from "./turn_html.js";
38
38
  import { createStageView } from "./stage_view.js";
39
39
  import { createFamilyPop, familyChipHtml, familyRowsHtml } from "./families_view.js";
40
- import { CONTINUE_OPENER, allViewChains, chainChipHtml, chainTitle, isOpener, chainDate, chainNeighbours, chainRowsHtml, continueRefusal, foldChains, openChildrenHtml } from "./chains.js";
40
+ import { CONTINUE_OPENER, allViewChains, chainChipHtml, chainTitle, isOpener, chainDate, chainNeighbours, chainRowsHtml, continueRefusal, continuedText, foldChains, openChildrenHtml } from "./chains.js";
41
41
  import { createTaskStopper } from "./task_stop.js";
42
42
  import { appendQuote } from "./annotations.js";
43
43
  import { parsePresets } from "./annotate_presets.js";
@@ -2863,8 +2863,9 @@ $("#infoArchiveBtn").addEventListener("click", () => {
2863
2863
  // Continue → starts the next link of the open session's chain (the server
2864
2864
  // waits up to 30 s for its recap, archives it, starts the new one with the
2865
2865
  // recap as a note) and opens it with the opener in the composer, not sent.
2866
- // A link continued already opens that next link; a refusal for delegates
2867
- // still open names them, each a link.
2866
+ // A link continued already opens that next link; its open delegates move
2867
+ // to the new link (the toast counts them); a refusal for open delegates
2868
+ // that can't move names them, each a link.
2868
2869
  // One continue per session at a time (the server locks it too); another
2869
2870
  // session's continue may run beside it.
2870
2871
  const continuingIds = new Set();
@@ -2881,12 +2882,12 @@ async function continueSelected() {
2881
2882
  addCreatedSession(created);
2882
2883
  const next = created.id;
2883
2884
  if (selected !== id) {
2884
- showToast(`Continued ${short} in ${next.slice(0, 8)}`, { label: "Open", run: () => select(next) });
2885
+ showToast(continuedText(id, created, { opened: false }), { label: "Open", run: () => select(next) });
2885
2886
  return;
2886
2887
  }
2887
2888
  await select(next);
2888
2889
  if (selected === next) fillOpener();
2889
- showToast(`Continued ${short} in ${next.slice(0, 8)}; ${short} is archived`);
2890
+ showToast(continuedText(id, created));
2890
2891
  } catch (e) {
2891
2892
  const refusal = continueRefusal(e, allSessions);
2892
2893
  if (refusal.kind === "open") {
@@ -201,7 +201,7 @@ export function chainRowsHtml(f, { activeId = null, now = Date.now() } = {}) {
201
201
  // What the page does with a refused continue (POST /api/sessions
202
202
  // continues, api()'s error with the answer's body): "open" the link that
203
203
  // continues it already (409 continued, next_id), "children" naming the
204
- // delegates still open (409 open_children, ids: {id, short, text} each,
204
+ // delegates still open that can't move (409 open_children, ids: {id, short, text} each,
205
205
  // the text its preview and why it is open: the server's word from its
206
206
  // detail, "<short> (running)", else its state here), or "error" with the
207
207
  // server's words.
@@ -224,11 +224,24 @@ export function continueRefusal(error, sessions = []) {
224
224
  return { kind: "error", text };
225
225
  }
226
226
 
227
- // The open-children refusal as the toast shows it: the delegates named,
228
- // each a link to its session.
227
+ // The open-children refusal as the toast shows it: the open delegates the
228
+ // continue can't move to the next link (an older chi's worker, a chi
229
+ // REPL), each a link to its session with what to do about it.
229
230
  // @param hrefFor (id) → its page's href
230
231
  export function openChildrenHtml(prevId, children, hrefFor) {
231
232
  const n = children.length;
232
233
  const items = children.map((c) => `<li><a href="${escapeHtml(hrefFor(c.id))}" class="cr-child">${escapeHtml(c.short)}</a>${c.text ? ` ${escapeHtml(c.text)}` : ""}</li>`).join("");
233
- return `<div class="continue-refused"><div>Can't continue ${escapeHtml(String(prevId).slice(0, 8))}: ${n} ${n === 1 ? "delegate is" : "delegates are"} still open. Wait for ${n === 1 ? "it" : "them"}, stop or archive ${n === 1 ? "it" : "them"}, then continue.</div><ul>${items}</ul></div>`;
234
+ return `<div class="continue-refused"><div>Can't continue ${escapeHtml(String(prevId).slice(0, 8))}: ${n} open ${n === 1 ? "delegate" : "delegates"} can't move to the next link. Do what ${n === 1 ? "it says" : "each says"}, or wait for ${n === 1 ? "it" : "them"}, then continue.</div><ul>${items}</ul></div>`;
235
+ }
236
+
237
+ // The toast after a continue: the new link, the previous one archived (when
238
+ // the page shows the new one), and how many open delegates moved to it
239
+ // (the answer's moved).
240
+ export function continuedText(prevId, created, { opened = true } = {}) {
241
+ const short = String(prevId).slice(0, 8);
242
+ const moved = Array.isArray(created?.moved) ? created.moved.length : 0;
243
+ const parts = [`Continued ${short} in ${String(created?.id || "").slice(0, 8)}`];
244
+ if (opened) parts.push(`${short} is archived`);
245
+ if (moved) parts.push(`${moved} open ${moved === 1 ? "delegate" : "delegates"} moved`);
246
+ return parts.join("; ");
234
247
  }
@@ -22,6 +22,7 @@ require_relative "iteration_limit"
22
22
  require_relative "session_commands"
23
23
  require_relative "model_profile"
24
24
  require_relative "child_reports"
25
+ require_relative "child_move"
25
26
  require_relative "context_absorber"
26
27
  require_relative "context_poller"
27
28
  require_relative "tools/task_runtime"
@@ -162,6 +163,11 @@ module Samagotchi
162
163
  def start
163
164
  # The wake turns' budget (delegate reports, attached context), from now.
164
165
  @wakes = WorkerWakes.new(grace: WAKE_START_GRACE)
166
+ # This worker holds the session now (a continue's starting marker held
167
+ # off any other until it did), and a continue that died moving
168
+ # delegates here left its intent: finish it.
169
+ ChildMove.clear_starting(@session_id, state_dir: @state_dir)
170
+ ChildMove.adopt(@session_id, state_dir: @state_dir)
165
171
  @session = Session.load(@session_id, state_dir: @state_dir)
166
172
  drop_dead_question
167
173
  @engine = build_engine
@@ -195,7 +201,8 @@ module Samagotchi
195
201
  ring_question(event) if event[:type] == :question_requested
196
202
  })
197
203
  # This session's own delegate children's news (rings in children/).
198
- @child_reports = ChildReports.new(session_id: @session_id, state_dir: @state_dir)
204
+ @child_reports = ChildReports.new(session_id: @session_id, state_dir: @state_dir,
205
+ predecessor: @session.continues)
199
206
  # Its attached context's notes (ContextSources).
200
207
  @context_absorber = ContextAbsorber.new(session_id: @session_id, state_dir: @state_dir,
201
208
  project_root: @session.project_root)
@@ -802,6 +809,10 @@ module Samagotchi
802
809
  restores = @turn_flow.restores
803
810
  begin
804
811
  @session.status = Session::STATUS_RUNNING
812
+ # A continue that moved this session to the chain's new link wrote
813
+ # the new parent beside it (Session::PARENT_FILE): this turn's
814
+ # status, its rings and every save from here on say so.
815
+ @session.parent_id = Session.parent_override(@session_id, state_dir: @state_dir) || @session.parent_id
805
816
  @session.save(state_dir: @state_dir)
806
817
  result = @engine.run_turn(@session, prompt, pending_input: pending_input_drain, **turn_args)
807
818
  rescue StandardError => e
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: samagotchi
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.47.0
4
+ version: 0.48.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmitry Dedov
@@ -211,6 +211,7 @@ files:
211
211
  - lib/samagotchi/bundles/system/memory_guide.md
212
212
  - lib/samagotchi/bundles/system/self_map.md
213
213
  - lib/samagotchi/cancellation_controller.rb
214
+ - lib/samagotchi/child_move.rb
214
215
  - lib/samagotchi/child_reports.rb
215
216
  - lib/samagotchi/children_status.rb
216
217
  - lib/samagotchi/cli/command.rb