samagotchi 0.32.0 → 0.34.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 (79) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +75 -1
  3. data/README.md +4 -1
  4. data/docs/context.md +8 -1
  5. data/docs/guardrails.md +31 -1
  6. data/docs/hooks.md +7 -4
  7. data/docs/memory.md +17 -1
  8. data/docs/plugins.md +79 -3
  9. data/docs/releasing.md +3 -2
  10. data/docs/sessions.md +9 -3
  11. data/lib/samagotchi/answer_command.rb +2 -1
  12. data/lib/samagotchi/archive_store.rb +1 -15
  13. data/lib/samagotchi/bridge.rb +8 -3
  14. data/lib/samagotchi/bundles/coordinator/manifest.yml +14 -0
  15. data/lib/samagotchi/bundles/coordinator/plugin.rb +114 -0
  16. data/lib/samagotchi/bundles/coordinator/skill_coordinator.md +60 -0
  17. data/lib/samagotchi/bundles/dev/manifest.yml +3 -2
  18. data/lib/samagotchi/bundles/mcp/manifest.yml +3 -3
  19. data/lib/samagotchi/bundles/mcp/plugin.rb +10 -11
  20. data/lib/samagotchi/bundles/system/delegated.md +1 -1
  21. data/lib/samagotchi/bundles/system/manifest.yml +4 -4
  22. data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
  23. data/lib/samagotchi/bundles/system/self_map.md +4 -2
  24. data/lib/samagotchi/child_reports.rb +31 -10
  25. data/lib/samagotchi/children_status.rb +157 -0
  26. data/lib/samagotchi/client_id.rb +52 -0
  27. data/lib/samagotchi/context_absorber.rb +2 -1
  28. data/lib/samagotchi/context_fetch.rb +14 -45
  29. data/lib/samagotchi/context_sources.rb +20 -10
  30. data/lib/samagotchi/cut_policy.rb +58 -0
  31. data/lib/samagotchi/engine.rb +24 -108
  32. data/lib/samagotchi/engine_peers.rb +30 -0
  33. data/lib/samagotchi/guardrail_wiring.rb +27 -2
  34. data/lib/samagotchi/guardrails/approvals.rb +7 -0
  35. data/lib/samagotchi/guardrails/child_boundary.rb +103 -0
  36. data/lib/samagotchi/guardrails/context.rb +32 -0
  37. data/lib/samagotchi/guardrails/parent_approvals.rb +0 -2
  38. data/lib/samagotchi/guardrails/shared_git.rb +124 -0
  39. data/lib/samagotchi/guardrails.rb +1 -0
  40. data/lib/samagotchi/kernel_loop.rb +9 -22
  41. data/lib/samagotchi/llm/chat_loop.rb +11 -22
  42. data/lib/samagotchi/memory_bundle/builder.rb +35 -1
  43. data/lib/samagotchi/memory_bundle/installer.rb +6 -2
  44. data/lib/samagotchi/memory_bundle/manifest.rb +28 -5
  45. data/lib/samagotchi/memory_bundle/provenance.rb +2 -1
  46. data/lib/samagotchi/memory_paths.rb +10 -0
  47. data/lib/samagotchi/plugin/api.rb +2 -2
  48. data/lib/samagotchi/plugin/sessions.rb +47 -2
  49. data/lib/samagotchi/process_group.rb +157 -0
  50. data/lib/samagotchi/prompt_warmup.rb +20 -1
  51. data/lib/samagotchi/question_desk.rb +2 -1
  52. data/lib/samagotchi/relay_verifier.rb +2 -4
  53. data/lib/samagotchi/reply_wait.rb +9 -4
  54. data/lib/samagotchi/send_command.rb +2 -1
  55. data/lib/samagotchi/session_manager.rb +2 -4
  56. data/lib/samagotchi/steer.rb +27 -19
  57. data/lib/samagotchi/steer_cut.rb +125 -0
  58. data/lib/samagotchi/system_prompt.rb +20 -8
  59. data/lib/samagotchi/terminal_ui/attached_loop.rb +5 -4
  60. data/lib/samagotchi/terminal_ui/formatting.rb +21 -12
  61. data/lib/samagotchi/terminal_ui.rb +2 -1
  62. data/lib/samagotchi/tool_declarations.rb +9 -5
  63. data/lib/samagotchi/tools/builtins.rb +2 -2
  64. data/lib/samagotchi/tools/delegate.rb +105 -16
  65. data/lib/samagotchi/tools/delegate_wait.rb +55 -13
  66. data/lib/samagotchi/tools/execute.rb +10 -81
  67. data/lib/samagotchi/tools/task_runtime.rb +25 -38
  68. data/lib/samagotchi/turn_note.rb +9 -1
  69. data/lib/samagotchi/version.rb +1 -1
  70. data/lib/samagotchi/web/app.rb +2 -1
  71. data/lib/samagotchi/web/public/app.js +19 -20
  72. data/lib/samagotchi/web/public/data.js +3 -0
  73. data/lib/samagotchi/web/public/question_card.js +3 -4
  74. data/lib/samagotchi/web/public/shown_texts.js +13 -0
  75. data/lib/samagotchi/web/public/stable_html.js +18 -0
  76. data/lib/samagotchi/web/public/turn_events.js +9 -2
  77. data/lib/samagotchi/web/public/turn_view.js +1 -1
  78. data/lib/samagotchi/worker.rb +40 -13
  79. metadata +14 -1
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The coordinator bundle (docs/plugins.md, The coordinator bundle): chi as a
4
+ # coordinator of parallel work. Its skill_coordinator memory holds the steps
5
+ # (split the work, one worktree and a wait: false delegate per task, check
6
+ # each report, ask before each merge); this plugin adds two commands.
7
+ #
8
+ # /children [all] shows this session's children as a card, newest first:
9
+ # state, branch, the last reply and whether it was reported (ChildrenStatus
10
+ # through ctx.sessions.children), with a Stop button per running or waiting
11
+ # child and Refresh. `all` adds archived children. The card has one id, so
12
+ # Refresh and Stop replace it. It is read on demand: no thread, no polling.
13
+ #
14
+ # /children stop <id> stops one of this session's own children
15
+ # (ctx.sessions.stop, by session id) and shows the card again.
16
+ #
17
+ # /coordinate <goal> asks the model, in this session, to follow the skill
18
+ # for the goal. A REPL session (--no-shared) takes no messages: the request
19
+ # is shown for the user to send.
20
+ #
21
+ # A child is asked before it changes anything outside its worktree by chi
22
+ # itself (the core child-boundary guardrail), in every mode: nothing to set up.
23
+ class Plugin
24
+ CARD_ID = "children"
25
+ # A card takes 6 actions: Refresh and up to 5 Stops.
26
+ MAX_STOPS = 5
27
+ STOPPABLE = %w[running waiting].freeze
28
+ TEXT_CHARS = 80
29
+ USAGE = "usage: /children [all] | /children stop <id>"
30
+ COORDINATE_USAGE = "usage: /coordinate <goal> — chi splits it into tasks, one child session and worktree each"
31
+
32
+ def initialize(_settings = {}); end
33
+
34
+ def register(chi)
35
+ chi.command "/children", "this session's child sessions: state, branch, last reply; all, stop <id>",
36
+ anytime: true do |args, ctx|
37
+ children_command(args.to_s.strip, ctx)
38
+ end
39
+ chi.command "/coordinate", "run work in parallel: chi splits <goal> into tasks, each in a child session and worktree",
40
+ anytime: true do |args, ctx|
41
+ coordinate(args.to_s.strip, ctx)
42
+ end
43
+ end
44
+
45
+ private
46
+
47
+ def children_command(args, ctx)
48
+ verb, rest = args.split(/\s+/, 2)
49
+ case verb
50
+ when nil then show(ctx, all: false)
51
+ when "all" then rest.to_s.empty? ? show(ctx, all: true) : USAGE
52
+ when "stop" then rest.to_s.strip.empty? ? USAGE : stop(rest.strip, ctx)
53
+ else USAGE
54
+ end
55
+ end
56
+
57
+ # The card, made or replaced; nil, so the command adds no text of its own.
58
+ def show(ctx, all:, note: nil)
59
+ children = ctx.sessions.children(all: all)
60
+ title = "children of #{ctx.session_id.to_s[0, 8]}"
61
+ title += " (#{children.size})" unless children.empty?
62
+ body = children.empty? ? "no children" : children.map { |child| line(child) }.join("\n")
63
+ body = "#{note}\n\n#{body}" if note
64
+ refresh = all ? "/children all" : "/children"
65
+ stops = children.select { |child| STOPPABLE.include?(child[:state]) }.first(MAX_STOPS)
66
+ .map { |child| { label: "Stop #{child[:short_id]}", command: "/children stop #{child[:short_id]}" } }
67
+ ctx.card(id: CARD_ID, title: title, body: body, actions: [{ label: "Refresh", command: refresh }, *stops])
68
+ nil
69
+ end
70
+
71
+ # - `ab12cd34` · running · fix/flaky · "fix the flaky spec…"
72
+ # - `9a8b7c6d` · done · feat/x · reported · "All 12 specs pass…"
73
+ # - `77aa66bb` · waiting (approval) · open it: chi --attach 77aa66bb
74
+ def line(child)
75
+ parts = ["`#{child[:short_id]}`", child[:waiting] ? "waiting (#{child[:waiting]})" : child[:state]]
76
+ parts << child[:branch] if child[:branch]
77
+ parts << "fork" unless child[:delegate]
78
+ parts << "archived" if child[:archived]
79
+ if child[:waiting]
80
+ parts << "open it: chi --attach #{child[:short_id]}"
81
+ elsif child[:last_reply]
82
+ parts << (child[:reported] ? "reported" : "not reported yet")
83
+ parts << quote(child[:last_reply])
84
+ elsif !child[:title].to_s.strip.empty?
85
+ parts << quote(child[:title])
86
+ end
87
+ "- #{parts.join(" · ")}"
88
+ end
89
+
90
+ def stop(id, ctx)
91
+ stopped = ctx.sessions.stop(id)
92
+ show(ctx, all: false, note: "stopped #{stopped[0, 8]}")
93
+ rescue Samagotchi::Plugin::Sessions::Error => e
94
+ "/children stop: #{e.message}"
95
+ end
96
+
97
+ def coordinate(goal, ctx)
98
+ return COORDINATE_USAGE if goal.empty?
99
+
100
+ request = "Read the skill_coordinator memory and follow it for this goal:\n\n#{goal}"
101
+ begin
102
+ ctx.sessions.send(ctx.session_id, request)
103
+ rescue Samagotchi::Plugin::Sessions::Error => e
104
+ return "/coordinate: #{e.message}. Send this yourself:\n\n#{request}"
105
+ end
106
+ "asked chi to coordinate it; a running turn gets it at its next step"
107
+ end
108
+
109
+ def quote(text)
110
+ line = text.to_s.gsub(/\s+/, " ").strip
111
+ line = "#{line[0, TEXT_CHARS - 1]}…" if line.length > TEXT_CHARS
112
+ "\"#{line}\""
113
+ end
114
+ end
@@ -0,0 +1,60 @@
1
+ # Skill: coordinator
2
+
3
+ Use when the user hands over work with several independent parts, or asks to "run it in parallel",
4
+ "delegate", "coordinate". The user decides; you do the legwork and keep them in the loop briefly.
5
+
6
+ ## Steps
7
+ 1. Find the repository's default branch, <default> below: `git symbolic-ref --short
8
+ refs/remotes/origin/HEAD` (drop the `origin/`), else `main` or `master`, whichever exists; ask the
9
+ user if unsure. Split the work into independent tasks (at most session.max_children at once,
10
+ default 4). Tell the user the split in a few lines and go on unless they object.
11
+ 2. For each task: from your own folder, `git worktree add ../<repo>-<task> -b <type>/<task>` with
12
+ execute; then `delegate` with `wait: false`, `cwd:` that worktree's absolute path, and a
13
+ self-contained task. The task starts with "Work only in <absolute worktree path>: every cd, read,
14
+ write, test and commit goes there, never the project root" and says what to report (the result,
15
+ evidence: commands and what they printed, what isn't done) and "commit on the branch, don't merge,
16
+ don't push".
17
+ 3. Tell the user what started (one line per child: its id, branch and task) and end your turn or keep
18
+ talking. Don't call delegate_result: chi brings each child's reply as a delegate report, also when
19
+ you are idle.
20
+ 4. On each report, check it before telling the user: `git -C <worktree> log --oneline <default>..` and
21
+ `git diff --stat <default>...<branch>`; run the tests the child names (execute with cwd: the worktree).
22
+ Say the result in one or two lines: done and verified / done with doubts / failed.
23
+ 5. Then one of: a follow-up (`delegate session: <id>`, narrower, `wait: false`; then end your turn:
24
+ its report comes by itself, so don't wait for it with delegate_result or task_wait), stop it
25
+ (`chi sessions stop <session id>` with execute; task_stop is for task ids, not children; the user
26
+ can use /children), or tell the user it is ready to merge.
27
+ 6. Merging is the user's call: ask (ask_user_question) per branch, "Merge <branch> into <default>?".
28
+ Before merging, in your own folder: `git branch --show-current` must print <default> (if it
29
+ doesn't, stop and ask the user; don't switch branches yourself); then check <default>:
30
+ `git log --oneline -3 <default>` and `git merge-base --is-ancestor <branch> <default>`; if the branch is already in, say so and don't merge
31
+ again. Merge with `git merge --ff-only <branch>` yourself, in your own folder. If that fails, ask the
32
+ child to rebase its branch on <default> (a follow-up that says "rebase only, don't merge") or ask the
33
+ user; don't rebase it from here (the branch is checked out in the child's worktree). Children
34
+ never merge. Never push unless asked.
35
+ 7. Clean up only a branch that was merged or that the user said to drop: stop its child
36
+ (`chi sessions stop <session id>`), then `git worktree remove ../<repo>-<task>`, then
37
+ `git branch -d <branch>` (in that order: a branch checked out in a worktree can't be deleted). If
38
+ `-d` refuses, say why to the user; never `-D` unless they say so. Remove only worktrees and
39
+ branches you created in this session. Every other branch stays as it is, its child, worktree and
40
+ branch too (the user said "keep it", or didn't say): leave them and tell the user they remain.
41
+ 8. When all are done, report: what merged, what didn't and why, what you cleaned up and what remains,
42
+ follow-ups found but not fixed. Report only what the commands' output showed: a step you planned
43
+ but didn't run, or whose output you didn't see, is not done; say so.
44
+
45
+ ## Gotchas
46
+ - Children are asked before changing anything outside their worktree (your checkout, a sibling's):
47
+ the user approves it on the child's card. Still check where commits landed (step 4).
48
+ - Never kill processes by a pid from a file or an old note; stop children with `chi sessions stop
49
+ <session id>` (execute) and background tasks with task_stop (task ids only: it doesn't stop a child).
50
+ - A child's tool approvals go to the user on the child's card; tell the user when a report says a
51
+ child waits for one.
52
+ - A report is the child's claim: verify it (step 4) before telling the user it is done, and check
53
+ where its commits landed: `git log --oneline <default>..<branch>` has them and `git status` in your own
54
+ folder is clean. A child that worked in your folder instead of its worktree: tell the user.
55
+ - Don't send a follow-up to a child that is still running; wait for its report.
56
+ - Don't start more children than the user asked for.
57
+
58
+ ## Changelog
59
+ - 2026-10-06 created (coordinator bundle 0.1.0)
60
+ - 2026-10-06 clean up only merged or dropped branches; report only what commands showed (0.1.2)
@@ -1,10 +1,10 @@
1
1
  ---
2
2
  name: dev
3
- version: 0.2.0
3
+ version: 0.3.0
4
4
  scope: system
5
5
  description: "Developer tools"
6
6
  trust_level: reviewed
7
- requires_chi: ">= 0.32.0"
7
+ requires_chi: ">= 0.34.0"
8
8
  includes:
9
9
  - known-names
10
10
  - mcp
@@ -12,3 +12,4 @@ includes:
12
12
  - skills
13
13
  - source-links
14
14
  - github-pr
15
+ - coordinator
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: mcp
3
- version: 0.4.4
3
+ version: 0.4.5
4
4
  scope: system
5
5
  description: "Tools from MCP servers (stdio): each server in config.yml bundles: mcp: servers: runs for the session, its tools are mcp_<server>_<tool>; /mcp lists them"
6
6
  trust_level: reviewed
7
7
  files: {}
8
8
  plugin:
9
9
  file: plugin.rb
10
- sha256: sha256:05543038e2950a5391655184f57a1028133eda8ca05f9e46e5cd2eddc3e9e608
11
- requires_chi: ">= 0.8.0"
10
+ sha256: sha256:1ebedc86cd21c918766c794015b6bd16c397c0f7e2734d939d8310d446b8a9a1
11
+ requires_chi: ">= 0.33.0"
@@ -31,6 +31,7 @@
31
31
  require "digest"
32
32
  require "json"
33
33
  require "open3"
34
+ require "samagotchi/process_group"
34
35
  require "time"
35
36
  require "shellwords"
36
37
  require "tmpdir"
@@ -59,6 +60,9 @@ class Plugin
59
60
  class Dead < Error; end
60
61
 
61
62
  POLL_SECONDS = 0.1
63
+ # How long close waits for the server after closing its stdin, after
64
+ # TERM, and after KILL.
65
+ STOP_GRACE_SECONDS = 1
62
66
 
63
67
  attr_reader :pid
64
68
 
@@ -136,14 +140,15 @@ class Plugin
136
140
  end
137
141
 
138
142
  # End the process: stdin closed first (most servers leave then), then
139
- # TERM and KILL to its process group.
143
+ # TERM and KILL to its process group (chi's ProcessGroup), a second's
144
+ # grace each.
140
145
  def close
141
146
  @mutex.synchronize { @closing = true }
142
147
  [@stdin].each { |io| io.close unless io.closed? }
143
- unless @wait.join(1)
144
- signal("TERM")
145
- signal("KILL") unless @wait.join(1)
146
- @wait.join(1)
148
+ unless @wait.join(STOP_GRACE_SECONDS)
149
+ Samagotchi::ProcessGroup.stop(@pid, grace: STOP_GRACE_SECONDS, poll: STOP_GRACE_SECONDS,
150
+ stopped: -> { !@wait.alive? }, wait: ->(seconds) { @wait.join(seconds) })
151
+ @wait.join(STOP_GRACE_SECONDS)
147
152
  end
148
153
  [@stdout, @stderr].each { |io| io.close unless io.closed? }
149
154
  [@reader, @err_reader].each { |t| t.join(1) }
@@ -234,12 +239,6 @@ class Plugin
234
239
  @on_exit&.call(reason) unless closing
235
240
  end
236
241
 
237
- def signal(name)
238
- Process.kill(name, -@pid)
239
- rescue Errno::ESRCH, Errno::EPERM
240
- nil
241
- end
242
-
243
242
  def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
244
243
  end
245
244
 
@@ -5,6 +5,6 @@ Another chi session (the parent) gave you this task. It reads only your last mes
5
5
  - Finish the task in this turn if you can. End with one reply the parent can act on without asking back: the result first, then evidence (paths, commands run and what they printed), then anything you could not verify or finish.
6
6
  - Do only what the task asks. A question ("why does this spec fail?", "what calls X?", "check whether…") gets a report, not file changes: say what you found and what change you would make, and leave making it to the parent. Edit files only when the task asks you to change, fix, add or write something, and then only the files that takes.
7
7
  - The parent may send follow-up messages later; each one is a new turn in this same session, with what you did so far.
8
- - Work in the directory you were started in. Don't change config, install bundles, edit managed files or write memories unless the task asks for it.
8
+ - Work in the directory you were started in: every cd, read, edit, test and commit stays in it (in a linked worktree, never the repository's other checkouts). Don't change config, install bundles, edit managed files or write memories unless the task asks for it.
9
9
  - You can't delegate further. Don't start other chi sessions, and send notes only to the parent.
10
10
  - If you are blocked, stop and say so in the reply with the exact question. Use ask_user_question only when the task says the user is watching.
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.32.0
3
+ version: 0.34.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:
7
7
  identity.md: sha256:29c2f7df9f6c5acbc5ec17000a4490741e55e426af15ca0d81c8706edd9bd027
8
- self_map.md: sha256:8b05db6989662e4409b34d73ce4368588c874501e6e917e6dee70dfb1e3f5157
8
+ self_map.md: sha256:eb017702746f9aad96404d9982a3c27bec717906fa2a14175ab207ad6f843f72
9
9
  config_modification_protocol.md: sha256:ec0b0c7023dbf03b93879e51cd5cdc38b22d43a2e5984ee11e8542ace077c3be
10
- memory_guide.md: sha256:17126f5adb9ef7b33c40e920ef339388c8c8c045b38a7de825b08760604db4e4
11
- delegated.md: sha256:eaaf66830d3ab1346c75812a5fc8fcb9d3dbaa6a3f6bcf47c103155d96e415b5
10
+ memory_guide.md: sha256:63d5d7f7d704e6ce7e28dd99e532b714c1b9f7acf548092404248d8ca5385b80
11
+ delegated.md: sha256:aedaa1e756a244904c5560fff0424e06fb468bbdcb0358bccefe321d67cd2614
@@ -15,7 +15,7 @@ This memory teaches you (the agent) how to use Samagotchi memories — persisten
15
15
  | `system` | `$XDG_CONFIG_HOME/samagotchi/memories/`, default `~/.config/samagotchi/memories/` (`MemoryPaths.system_dir`) | User-wide preferences, identity, cross-project knowledge |
16
16
  | `project` | `<system dir>/projects/<basename>_<hash>/` (`MemoryPaths.project_dir`: `basename(root)` + 8-char `MD5(root)`, root = `MemoryPaths.project_root`) | Repo-specific conventions, workflow, stack decisions |
17
17
 
18
- - The project root is the git repository: its common git dir, which every linked worktree shares. All worktrees and subdirectories of one repository share one project folder; the system prompt shows the resolved root and folder. Outside a repository the root is the working directory. A separate clone is a different project.
18
+ - The project root is the git repository: its common git dir, which every linked worktree shares. All worktrees and subdirectories of one repository share one project folder; the system prompt shows that folder (not the root: in a linked worktree, work stays in the worktree). Outside a repository the root is the working directory. A separate clone is a different project.
19
19
  - `index.md` lives in each scope dir and contains auto-managed lines like `- **name** · scope · date · bytes — description`; a memory a bundle installed adds `· from <bundle>` before the description. Your free-form sections in `index.md` are preserved but managed lines are owned by `memory_write` (and refreshed when `write`/`edit` change a memory's file).
20
20
 
21
21
  ## Tools you have
@@ -30,8 +30,10 @@
30
30
  `chi send --new --wait -m TEXT` starts a session the user can watch in the web and prints its
31
31
  answer (exit 3: it waits for the user's answer); see "Starting a session".
32
32
  - `delegate` hands a task to a child session that runs in parallel and returns only its final
33
- reply (`delegate_result` waits for it; with `wait: false` chi brings the reply to me by itself
34
- as a delegate report when the child ends its turn; `session:` sends a follow-up to a child). A child is a
33
+ reply (`wait: false` is the usual way: I end my turn or keep talking with my user, and chi
34
+ brings the reply to me by itself as a delegate report when the child ends its turn, starting a
35
+ turn for it if I'm idle; `delegate_result` only when this turn can't go on without the reply;
36
+ `session:` sends a follow-up to a child). A child is a
35
37
  normal session: it shows in `chi sessions list` with `↳ <parent>`, and the user can steer it
36
38
  with `chi --attach <id>`. While I wait for a child, its tool approvals go to my user as my own
37
39
  approvals (I see only how each was answered); see `docs/sessions.md` "Delegating".
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "fileutils"
4
+ require_relative "client_id"
4
5
  require_relative "session"
5
6
  require_relative "session_inbox"
6
7
  require_relative "reply_wait"
@@ -29,6 +30,13 @@ module Samagotchi
29
30
  # Turns an idle parent runs for reports in a row with no human input.
30
31
  MAX_WAKES_KEY = "session.max_wakes"
31
32
  MAX_WAKES_DEFAULT = 10
33
+ # How long a leaving worker waits for the parent wakes it started
34
+ # (#await_wakes): a wake only spawns a worker, well under a second.
35
+ WAKE_JOIN_SECONDS = 5
36
+
37
+ # The wake threads #ring started in this process, until #await_wakes.
38
+ @wakes = []
39
+ @wakes_mutex = Mutex.new
32
40
 
33
41
  module_function
34
42
 
@@ -70,15 +78,29 @@ module Samagotchi
70
78
  def wake_parent(parent_id, state_dir:, wake:)
71
79
  return if SessionManager.session_owner(parent_id, state_dir: state_dir)
72
80
 
73
- if wake
74
- wake.call(parent_id)
75
- else
76
- Thread.new do
77
- SessionManager.wake_for_report(parent_id, state_dir: state_dir)
78
- rescue StandardError => e
79
- Log.warn(:worker, "delegate_wake_failed", parent: parent_id[0, 8], error: e.class.name, msg: e.message)
80
- end
81
+ return wake.call(parent_id) if wake
82
+
83
+ thread = Thread.new do
84
+ SessionManager.wake_for_report(parent_id, state_dir: state_dir)
85
+ rescue StandardError => e
86
+ Log.warn(:worker, "delegate_wake_failed", parent: parent_id[0, 8], error: e.class.name, msg: e.message)
81
87
  end
88
+ @wakes_mutex.synchronize { @wakes = @wakes.select(&:alive?) << thread }
89
+ thread
90
+ end
91
+
92
+ # Waits up to +timeout+ for the parent wakes this process started: a
93
+ # worker's teardown, since its process exits right after (a crash's
94
+ # ring comes just before it) and an exit kills a wake midway, before
95
+ # it spawned the parent's worker.
96
+ # @return [Integer] the wakes still running after it
97
+ def await_wakes(timeout = WAKE_JOIN_SECONDS)
98
+ threads = @wakes_mutex.synchronize { @wakes.tap { @wakes = [] } }
99
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
100
+ threads.each { |thread| thread.join([deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC), 0].max) }
101
+ left = threads.count(&:alive?)
102
+ Log.warn(:worker, "delegate_wakes_left", count: left) if left.positive?
103
+ left
82
104
  end
83
105
  end
84
106
 
@@ -93,7 +115,6 @@ module Samagotchi
93
115
  # failed: the next turn reads the same rings again.
94
116
  class ChildReports
95
117
  SOURCE = Steer::DELEGATE_REPORT
96
- CLIENT_PREFIX = Steer::CHILD_CLIENT_PREFIX
97
118
 
98
119
  # A report for the model: the text delegate_result would return
99
120
  # (DelegateWait.finish, its report variant), the cursor it moves the child to,
@@ -101,7 +122,7 @@ module Samagotchi
101
122
  # itself, with delegate_result, leaves it alone).
102
123
  Report = Data.define(:child_id, :text, :before, :after, :rings) do
103
124
  def line(mark: nil) = Steer::Line.new(text: text, source: ChildReports::SOURCE, mark: mark)
104
- def origin = { client_id: "#{ChildReports::CLIENT_PREFIX}#{child_id[0, 8]}" }
125
+ def origin = { client_id: "#{ClientId::CHILD_PREFIX}#{child_id[0, 8]}" }
105
126
  end
106
127
 
107
128
  def initialize(session_id:, state_dir:)
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+ require_relative "session"
5
+ require_relative "archive_store"
6
+ require_relative "memory_paths"
7
+ require_relative "reply_wait"
8
+ require_relative "tools/delegate"
9
+ require_relative "tools/delegate_cursor"
10
+
11
+ module Samagotchi
12
+ # Loaded on first use: session_manager requires terminal_ui, which
13
+ # requires KernelLoop and so the tools (a require cycle otherwise).
14
+ autoload :SessionManager, File.expand_path("session_manager", __dir__)
15
+
16
+ # A parent's children as `/children` and the glance views show them: one
17
+ # Child per child session, read from the files layer 1 already writes
18
+ # (the session JSON, its output/ replies, the parent's delegates.json).
19
+ # Nothing here runs git or a subprocess.
20
+ module ChildrenStatus
21
+ # One child of a session.
22
+ # @!attribute state [String] waiting, running, failed, stopped, done or
23
+ # idle (STATES, in the order they are decided)
24
+ # @!attribute waiting [String, nil] what it waits for: question,
25
+ # approval, hook or continue
26
+ # @!attribute delegate [Boolean] a `delegate` child; false for a fork
27
+ # @!attribute branch [String, nil] the branch checked out in its folder
28
+ # (a short sha when detached); nil outside git
29
+ # @!attribute last_reply [String, nil] the first line of its newest reply
30
+ # @!attribute reported [Boolean] the parent was already given that reply
31
+ # (a delegate report or a wait); false when there is none
32
+ Child = Data.define(:id, :short_id, :title, :state, :waiting, :live, :delegate, :cwd, :branch,
33
+ :last_reply, :last_reply_at, :reported, :updated_at, :archived)
34
+
35
+ # The glance views' numbers: children running, waiting for an answer,
36
+ # and with a reply the parent wasn't given yet.
37
+ Counts = Data.define(:running, :waiting, :unreported) do
38
+ def self.none = new(running: 0, waiting: 0, unreported: 0)
39
+ end
40
+
41
+ STATES = %w[waiting running failed stopped done idle].freeze
42
+
43
+ module_function
44
+
45
+ # Every child of +parent_id+, newest first: delegates and forks (a fork
46
+ # is delegate: false), archived ones only with +include_archived+. Scans
47
+ # every session (SessionManager.children_of): for on-demand use.
48
+ # @return [Array<Child>]
49
+ def of(parent_id, state_dir:, include_archived: false)
50
+ cursors = Tools::DelegateCursors.read(parent_id, state_dir: state_dir)
51
+ SessionManager.children_of(parent_id, state_dir: state_dir).filter_map do |row|
52
+ next if row[:archived] && !include_archived
53
+
54
+ session = load(row[:id], state_dir: state_dir)
55
+ session && child(session, row, cursors, state_dir: state_dir)
56
+ end
57
+ end
58
+
59
+ # The cheap path for the glance views: only the children in the
60
+ # parent's delegates.json (one Session.load and an owner check each, no
61
+ # scan). Its keys can also name a fork the model passed to delegate
62
+ # session: or a deleted child: only live delegates of this parent count,
63
+ # archived ones don't.
64
+ # @return [Counts]
65
+ def counts(parent_id, state_dir:)
66
+ cursors = Tools::DelegateCursors.read(parent_id, state_dir: state_dir)
67
+ children = cursors.keys.filter_map do |id|
68
+ session = load(id, state_dir: state_dir)
69
+ next unless session&.delegate? && session.parent_id == parent_id
70
+ next if ArchiveStore.archived?(Session.session_dir(id, state_dir: state_dir))
71
+
72
+ child(session, summary_of(session, state_dir: state_dir), cursors, state_dir: state_dir)
73
+ end
74
+ Counts.new(running: children.count { |c| c.state == "running" },
75
+ waiting: children.count { |c| c.state == "waiting" },
76
+ unreported: children.count { |c| c.last_reply && !c.reported })
77
+ end
78
+
79
+ # @param row [Hash] a children_of row, or summary_of's keys
80
+ # @param cursors [Hash] the parent's delegates.json
81
+ def child(session, row, cursors, state_dir:)
82
+ reply = ReplyWait.newest_reply(session.id, state_dir: state_dir)
83
+ cursor = Tools::DelegateCursors.get_from(cursors, session.id)
84
+ Child.new(id: session.id, short_id: session.id[0, 8], title: row[:preview].to_s,
85
+ state: state_of(session, row, reply), waiting: row[:waiting], live: !!row[:live],
86
+ delegate: session.delegate?, cwd: session.working_directory, branch: branch(session.working_directory),
87
+ last_reply: reply && first_line(session.id, reply, state_dir: state_dir),
88
+ last_reply_at: reply && reply_time(reply), reported: !reply.nil? && cursor.reply_file == reply,
89
+ updated_at: session.updated_at, archived: !!row[:archived])
90
+ end
91
+ private_class_method :child
92
+
93
+ def state_of(session, row, reply)
94
+ return "waiting" if row[:waiting]
95
+ return "running" if Tools::Delegate.running?(row)
96
+ return "stopped" if session.status == Session::STATUS_STOPPED
97
+
98
+ outcome = session.last_turn.is_a?(Hash) ? session.last_turn["outcome"] : nil
99
+ return "failed" if session.status == Session::STATUS_ERROR || outcome == "failed"
100
+ return "done" if outcome == "completed" && reply
101
+
102
+ "idle"
103
+ end
104
+ private_class_method :state_of
105
+
106
+ # The keys of a children_of row that state_of and child read, for a
107
+ # session counts loaded itself.
108
+ def summary_of(session, state_dir:)
109
+ live = SessionManager.worker_live?(session.id, state_dir: state_dir)
110
+ { live: live, busy: live && session.status == Session::STATUS_RUNNING, status: session.status,
111
+ updated_at: session.updated_at, waiting: session.waiting_question(live: live)&.dig(:kind),
112
+ preview: session.last_prompt.to_s }
113
+ end
114
+ private_class_method :summary_of
115
+
116
+ # The branch from the folder's own HEAD file (a linked worktree's is
117
+ # under .git/worktrees/<name>), read without git.
118
+ # @return [String, nil]
119
+ def branch(cwd)
120
+ dir = MemoryPaths.git_dir(cwd.to_s)
121
+ head = dir && File.read(File.join(dir, "HEAD"), 512).strip
122
+ return nil if head.nil? || head.empty?
123
+
124
+ head.start_with?("ref:") ? head.delete_prefix("ref:").strip.delete_prefix("refs/heads/") : head[0, 8]
125
+ rescue SystemCallError
126
+ nil
127
+ end
128
+ private_class_method :branch
129
+
130
+ def first_line(id, file, state_dir:)
131
+ text = File.read(File.join(ReplyWait.reply_dir(id, state_dir: state_dir), file), 4096)
132
+ text.each_line.map(&:strip).find { |line| !line.empty? }.to_s
133
+ rescue SystemCallError
134
+ nil
135
+ end
136
+ private_class_method :first_line
137
+
138
+ # Reply files are named by the local time they were written
139
+ # (SessionInbox.write_output, %Y%m%d%H%M%S then nanoseconds).
140
+ # @return [Time, nil] nil for a name that isn't one
141
+ def reply_time(file)
142
+ Time.strptime(File.basename(file, ".txt")[0, 14], "%Y%m%d%H%M%S")
143
+ rescue ArgumentError, TypeError
144
+ nil
145
+ end
146
+ private_class_method :reply_time
147
+
148
+ def load(id, state_dir:)
149
+ return nil unless Session.exist?(id, state_dir: state_dir)
150
+
151
+ Session.load(id, state_dir: state_dir)
152
+ rescue ArgumentError
153
+ nil
154
+ end
155
+ private_class_method :load
156
+ end
157
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ # Who sent a turn, a line or an answer: its client_id, as a turn's origin,
5
+ # an input file and the events carry it. The one table of the ids chi
6
+ # makes, and of which of them are a human's input (#human?). No requires,
7
+ # so any file can use it.
8
+ module ClientId
9
+ # A web tab (web:<random>, per tab); web:restart, the page's Restart.
10
+ WEB_PREFIX = "web:"
11
+ WEB_RESTART = "web:restart"
12
+ # An attached chi TUI: tui:<pid>.
13
+ TUI_PREFIX = "tui:"
14
+ # chi send's turns and commands.
15
+ CLI_SEND = "cli:send"
16
+ # chi answer's answers (a parent agent's, Guardrails::ParentApprovals).
17
+ CLI_ANSWER = "cli:answer"
18
+ # chi restart.
19
+ CLI_RESTART = "cli:restart"
20
+ # A delegating parent's task or follow-up: delegate:<parent8>.
21
+ DELEGATE_PREFIX = "delegate:"
22
+ # A delegate child's report (ChildReports): child:<child8>.
23
+ CHILD_PREFIX = "child:"
24
+ # A turn chi ran because an attached context source changed:
25
+ # context:<name> (Worker's context wake).
26
+ CONTEXT_PREFIX = "context:"
27
+ # chi's own turns; system:reminder, the turn queued when reminders are due.
28
+ SYSTEM_PREFIX = "system:"
29
+ REMINDER = "system:reminder"
30
+ # A plugin's message (Plugin::Sessions#send).
31
+ PLUGIN = "plugin"
32
+ # A relayed answer (RelayVerifier): relay:<parent8>.
33
+ RELAY_PREFIX = "relay:"
34
+
35
+ # Input a human typed: a web tab, a chi TUI, chi send, and nil (a
36
+ # worker's initial prompt; a web client may send none). An allowlist:
37
+ # delegates, plugins, reminders, context changes and any id chi doesn't
38
+ # know are not.
39
+ HUMAN_PREFIXES = [WEB_PREFIX, TUI_PREFIX].freeze
40
+ HUMAN_IDS = [CLI_SEND].freeze
41
+
42
+ module_function
43
+
44
+ # Whether +client_id+ (a turn's or a line's origin) is a human's input.
45
+ def human?(client_id)
46
+ return true if client_id.nil?
47
+
48
+ id = client_id.to_s
49
+ HUMAN_IDS.include?(id) || HUMAN_PREFIXES.any? { |prefix| id.start_with?(prefix) }
50
+ end
51
+ end
52
+ end
@@ -118,7 +118,8 @@ module Samagotchi
118
118
  if sub.seen != snapshot.revision
119
119
  args = { name: name, hint: hint, summary: snapshot.summary, changes: snapshot.serial - sub.seen_serial }
120
120
  found = delivery(name, text_note_id(name, snapshot), self.class.updated_text(**args), now, seen(sub, snapshot))
121
- return found unless snapshot.wake
121
+ # Any revision since the one the session last saw may have asked.
122
+ return found unless snapshot.wake_serial.to_i > sub.seen_serial
122
123
 
123
124
  return found.with(wake_note: found.note.merge(text: self.class.updated_text(**args, wake: true)))
124
125
  end