samagotchi 0.90.0 → 0.92.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 (85) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +79 -1
  3. data/README.md +5 -2
  4. data/bin/chi +4 -4
  5. data/docs/architecture.md +17 -13
  6. data/docs/bundle-authoring.md +6 -11
  7. data/docs/cli.md +7 -4
  8. data/docs/configuration.md +8 -4
  9. data/docs/context.md +5 -3
  10. data/docs/hooks.md +3 -3
  11. data/docs/internals/background-tasks.md +27 -2
  12. data/docs/internals/bundles.md +4 -4
  13. data/docs/internals/engine.md +21 -8
  14. data/docs/internals/prompt-caching.md +4 -0
  15. data/docs/internals/tool-guardrails.md +10 -2
  16. data/docs/memory.md +6 -0
  17. data/docs/plugins.md +30 -13
  18. data/docs/releasing.md +3 -0
  19. data/docs/sessions.md +4 -4
  20. data/lib/samagotchi/bridge.rb +11 -7
  21. data/lib/samagotchi/bundle_command/check.rb +43 -12
  22. data/lib/samagotchi/bundle_command/scaffold.rb +12 -0
  23. data/lib/samagotchi/bundle_command/test_run.rb +12 -0
  24. data/lib/samagotchi/bundle_command/try/command.rb +164 -0
  25. data/lib/samagotchi/bundle_command/try/render.rb +80 -0
  26. data/lib/samagotchi/bundle_command/try/run.rb +35 -17
  27. data/lib/samagotchi/bundle_command/try/sandbox.rb +113 -34
  28. data/lib/samagotchi/bundle_command/try/trace.rb +26 -15
  29. data/lib/samagotchi/bundle_command.rb +11 -213
  30. data/lib/samagotchi/bundle_session_settings.rb +82 -45
  31. data/lib/samagotchi/bundles/core/manifest.yml +2 -2
  32. data/lib/samagotchi/bundles/cost-cap/manifest.yml +6 -5
  33. data/lib/samagotchi/bundles/cost-cap/plugin.rb +57 -12
  34. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  35. data/lib/samagotchi/bundles/system/self_map.md +5 -0
  36. data/lib/samagotchi/child_env.rb +50 -0
  37. data/lib/samagotchi/client_id.rb +3 -0
  38. data/lib/samagotchi/config.rb +3 -0
  39. data/lib/samagotchi/context_note.rb +13 -6
  40. data/lib/samagotchi/engine.rb +31 -15
  41. data/lib/samagotchi/hooks/registry.rb +2 -0
  42. data/lib/samagotchi/kernel_loop.rb +14 -3
  43. data/lib/samagotchi/last_turn.rb +3 -2
  44. data/lib/samagotchi/llm/chat_loop.rb +3 -1
  45. data/lib/samagotchi/llm/errors.rb +4 -4
  46. data/lib/samagotchi/memory_bundle/builder.rb +3 -2
  47. data/lib/samagotchi/memory_bundle/installed_bundle.rb +7 -2
  48. data/lib/samagotchi/memory_bundle/installer.rb +1 -0
  49. data/lib/samagotchi/memory_bundle/manifest.rb +42 -8
  50. data/lib/samagotchi/memory_bundle/provenance.rb +2 -1
  51. data/lib/samagotchi/parent_report.rb +12 -0
  52. data/lib/samagotchi/reply_wait.rb +6 -2
  53. data/lib/samagotchi/sandbox_layout.rb +66 -0
  54. data/lib/samagotchi/send_command.rb +3 -3
  55. data/lib/samagotchi/session_manager.rb +12 -20
  56. data/lib/samagotchi/task_finish_note.rb +90 -0
  57. data/lib/samagotchi/task_watch.rb +228 -0
  58. data/lib/samagotchi/terminal_ui/attached_loop.rb +19 -4
  59. data/lib/samagotchi/terminal_ui/formatting.rb +7 -2
  60. data/lib/samagotchi/testing/bundle.rb +32 -19
  61. data/lib/samagotchi/testing/install.rb +26 -17
  62. data/lib/samagotchi/testing/sandbox.rb +45 -37
  63. data/lib/samagotchi/testing/waiting.rb +5 -7
  64. data/lib/samagotchi/tool_declarations.rb +16 -0
  65. data/lib/samagotchi/tool_response.rb +28 -6
  66. data/lib/samagotchi/tools/builtins.rb +5 -1
  67. data/lib/samagotchi/tools/task_record.rb +9 -0
  68. data/lib/samagotchi/tools/task_runtime.rb +2 -2
  69. data/lib/samagotchi/tools/task_wait.rb +10 -3
  70. data/lib/samagotchi/turn_checkpoint.rb +23 -10
  71. data/lib/samagotchi/version.rb +1 -1
  72. data/lib/samagotchi/web/public/app.js +24 -14
  73. data/lib/samagotchi/web/public/cards_place.js +4 -1
  74. data/lib/samagotchi/web/public/images.js +6 -0
  75. data/lib/samagotchi/web/public/model_pick.js +10 -0
  76. data/lib/samagotchi/web/public/notify.js +4 -4
  77. data/lib/samagotchi/web/public/toasts.js +17 -9
  78. data/lib/samagotchi/web/public/turn_events.js +13 -1
  79. data/lib/samagotchi/web/public/unqueue.js +20 -4
  80. data/lib/samagotchi/worker.rb +104 -42
  81. data/lib/samagotchi/worker_idle_exit.rb +26 -3
  82. data/lib/samagotchi/worker_inbound.rb +18 -4
  83. data/lib/samagotchi/worker_task_wakes.rb +160 -0
  84. data/lib/samagotchi/worker_wakes.rb +11 -4
  85. metadata +8 -1
@@ -154,7 +154,7 @@ module Samagotchi
154
154
  # source, recorded as DEV_SHA (no sha256 check) with "dev": true
155
155
  def write(files:, scope:, version:, source_path:, hooks: {}, trust_level: nil, source_commit: nil, hooks_files: {},
156
156
  guardrails_files: {}, plugin_file: nil, requires_chi: nil, needs: nil, conflicts: [], includes: nil,
157
- scripts_files: {}, context_providers: [], file_descriptions: {}, session_settings: [], dev: false)
157
+ scripts_files: {}, context_providers: [], file_descriptions: {}, session_settings: [], session_setting_types: {}, dev: false)
158
158
  FileUtils.mkdir_p(@bundle_dir)
159
159
  bases_dir = File.join(@bundle_dir, "bases")
160
160
  FileUtils.mkdir_p(bases_dir)
@@ -255,6 +255,7 @@ module Samagotchi
255
255
  end
256
256
  manifest_data["context_providers"] = context_providers.map(&:to_h) unless context_providers.empty?
257
257
  manifest_data["session_settings"] = Array(session_settings).map(&:to_s) unless Array(session_settings).empty?
258
+ manifest_data["session_setting_types"] = session_setting_types.to_h { |k, v| [k.to_s, v.to_s] } unless session_setting_types.to_h.empty?
258
259
 
259
260
  write_manifest(manifest_data)
260
261
  end
@@ -6,6 +6,7 @@ require "json"
6
6
  require_relative "cli/exit"
7
7
  require_relative "pending_question"
8
8
  require_relative "session"
9
+ require_relative "turn_checkpoint"
9
10
  require_relative "guardrails/parent_policy"
10
11
 
11
12
  module Samagotchi
@@ -339,6 +340,8 @@ module Samagotchi
339
340
  end
340
341
 
341
342
  def no_reply_line(result, session_id = nil, strategy: nil)
343
+ return command_line(result) if result.origin == TurnCheckpoint::COMMAND_ORIGIN
344
+
342
345
  case result.outcome
343
346
  when "failed" then failed_line(result)
344
347
  when "canceled"
@@ -360,6 +363,15 @@ module Samagotchi
360
363
  end
361
364
  end
362
365
 
366
+ # A file-delivered session command's outcome: no turn ran for it.
367
+ def command_line(result)
368
+ case result.outcome
369
+ when "completed" then "the command ran (no turn)"
370
+ when "failed" then "the command failed"
371
+ else "the command was refused (#{result.cancel_reason || "canceled"})"
372
+ end
373
+ end
374
+
363
375
  # A failed turn's line; one whose work stayed says so, so the task isn't
364
376
  # sent again over what it changed.
365
377
  def failed_line(result)
@@ -59,8 +59,11 @@ module Samagotchi
59
59
  # :canceled and :timeout
60
60
  # @!attribute turn_id [String, nil] the id of the turn this result is
61
61
  # about, when known
62
+ # @!attribute origin [String, nil] "command" for a :no_reply that a
63
+ # file-delivered session command settled (no turn ran:
64
+ # TurnCheckpoint.record_command_outcome); nil otherwise
62
65
  Result = Struct.new(:status, :text, :file, :question, :outcome, :limit, :error_kind, :retryable, :cancel_reason,
63
- :stopped_by, :kept_steps, :session, :turn_id, keyword_init: true)
66
+ :stopped_by, :kept_steps, :session, :turn_id, :origin, keyword_init: true)
64
67
 
65
68
  module_function
66
69
 
@@ -163,7 +166,8 @@ module Samagotchi
163
166
  # command finished without a turn. After the reply check: a worker
164
167
  # killed between writing the reply and its idle save left the reply.
165
168
  if turn && (settled = TurnCheckpoint.settled_turn(session_dir, turn))
166
- return Result.new(status: :no_reply, outcome: settled.outcome, session: session, **settled.stop_facts)
169
+ return Result.new(status: :no_reply, outcome: settled.outcome, session: session, origin: settled.origin,
170
+ **settled.stop_facts)
167
171
  end
168
172
 
169
173
  case session.status
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "tmpdir"
5
+ require "yaml"
6
+ require_relative "paths"
7
+
8
+ module Samagotchi
9
+ # The temp folders a sandboxed chi runs in, shared by `chi bundle try`'s
10
+ # child (BundleCommand::Try::Sandbox) and the test kit
11
+ # (Testing::Sandbox): one realpath'd root holding config/, state/,
12
+ # work/ and whatever else the sandbox wants, the XDG pair pointing at
13
+ # them, and the switches every sandbox turns off. Where the environment
14
+ # goes is each sandbox's own: try builds its child's Hash, the kit
15
+ # changes ENV.
16
+ class SandboxLayout
17
+ DIRS = %w[config state work].freeze
18
+ # No recap request and no cache warm-up request in a sandbox.
19
+ QUIET_ENV = { "SAMAGOTCHI_RECAP_ENABLED" => "false", "SAMAGOTCHI_CACHE_WARMUP" => "off" }.freeze
20
+
21
+ # A new temp root (Dir.mktmpdir +prefix+, its realpath) with DIRS and
22
+ # +extra+ folders in it.
23
+ # @return [SandboxLayout]
24
+ def self.create(prefix, extra: [])
25
+ root = File.realpath(Dir.mktmpdir(prefix))
26
+ (DIRS + extra).each { |dir| FileUtils.mkdir_p(File.join(root, dir)) }
27
+ new(root)
28
+ rescue StandardError
29
+ FileUtils.rm_rf(root) if root
30
+ raise
31
+ end
32
+
33
+ # @return [String]
34
+ attr_reader :root
35
+
36
+ def initialize(root)
37
+ @root = root
38
+ end
39
+
40
+ def dir(name) = File.join(root, name)
41
+
42
+ def config_home = dir("config")
43
+
44
+ def state_home = dir("state")
45
+
46
+ def work_dir = dir("work")
47
+
48
+ # XDG_CONFIG_HOME and XDG_STATE_HOME at the sandbox's, and QUIET_ENV.
49
+ # @return [Hash{String=>String}]
50
+ def env = { "XDG_CONFIG_HOME" => config_home, "XDG_STATE_HOME" => state_home }.merge(QUIET_ENV)
51
+
52
+ # config.yml under +config_home+ (default the sandbox's): a Hash (as
53
+ # YAML) or YAML text.
54
+ # @return [String] its path
55
+ def write_config(data, config_home: self.config_home)
56
+ path = File.join(config_home, Paths::APP_DIR, "config.yml")
57
+ FileUtils.mkdir_p(File.dirname(path))
58
+ File.write(path, data.is_a?(String) ? data : YAML.dump(data))
59
+ path
60
+ end
61
+
62
+ def remove
63
+ FileUtils.rm_rf(root)
64
+ end
65
+ end
66
+ end
@@ -93,9 +93,9 @@ module Samagotchi
93
93
  --set BUNDLE.KEY=VALUE
94
94
  (--new) a bundle setting for this session, over
95
95
  config.yml's bundles: (repeatable; a key its bundle's
96
- manifest lists in session_settings:; 3, 2.5, true,
97
- false and off are typed, the rest is text:
98
- --set cost-cap.cap=3)
96
+ manifest lists in session_settings:; numbers (3,
97
+ .5) and booleans (true, off, no) are typed, the
98
+ rest is text: --set cost-cap.cap=3)
99
99
  --parent-answers LEVEL
100
100
  (--new) what a parent agent may answer on its
101
101
  questions: hard, standard or soft, only stricter than
@@ -5,6 +5,7 @@ require "json"
5
5
  require "time"
6
6
  require "securerandom"
7
7
  require "rbconfig"
8
+ require_relative "child_env"
8
9
  require_relative "client_id"
9
10
  require_relative "analytics_file"
10
11
  require_relative "atomic_file"
@@ -210,7 +211,7 @@ module Samagotchi
210
211
  # is when the worker starts); opts[:env] adds to it (merged, not
211
212
  # replaced) what it can't read there: this `chi`'s CLI settings
212
213
  # (Config.cli_env) and the absolute log file. It unsets
213
- # SAMAGOTCHI_GUARDRAILS_* and the session markers (SESSION_MARKER_ENV).
214
+ # what ChildEnv lists (SAMAGOTCHI_GUARDRAILS_*, the session markers).
214
215
  # XDG_CONFIG_HOME passes: whoever starts or
215
216
  # wakes the worker picks which config.yml it reads (docs/guardrails.md).
216
217
  # XDG_STATE_HOME and XDG_CONFIG_HOME, when set, pass made absolute
@@ -244,18 +245,15 @@ module Samagotchi
244
245
  # And the spawner's XDG dirs, absolute: a relative XDG_STATE_HOME or
245
246
  # XDG_CONFIG_HOME would otherwise be read from the session's directory.
246
247
  child_env.merge!(Paths.absolute_xdg)
247
- # The spawner's environment must not reach the worker's guardrails (a
248
- # parent agent starting or waking a session): Process.spawn unsets a
249
- # key mapped to nil. config.yml no longer reads them; this keeps an
250
- # older or future env-exposed one out as well.
251
- ENV.each_key { |key| child_env[key] = nil if key.start_with?(GUARDRAILS_ENV_PREFIX) }
252
- # The markers a session's execute exports (Tools::Builtins.parent_env)
253
- # are that session's: a worker started from its commands (chi send)
254
- # is another session. Its execute and tasks set its own; nothing else
255
- # it runs (a user's !cmd, an MCP server, a context command) has one.
256
- SESSION_MARKER_ENV.each { |key| child_env[key] = nil }
257
- # A smoke's fake "newest installed" is for this process's notices only.
258
- child_env[InstalledVersions::ENV_KEY] = nil
248
+ # Unset (Process.spawn unsets a key mapped to nil) what no child chi
249
+ # takes from its spawner (ChildEnv): the spawner's guardrails settings
250
+ # (a parent agent starting or waking a session; config.yml no longer
251
+ # reads them, this keeps an older or future env-exposed one out as
252
+ # well); the session markers, a session's own (a worker started from
253
+ # its commands, chi send, is another session: its execute and tasks
254
+ # set its own; nothing else it runs has one); and a smoke's fake
255
+ # "newest installed", for this process's notices only.
256
+ child_env.merge!(ChildEnv.unset)
259
257
  # A worker process: run_session_loop traps signals, and leaves this
260
258
  # process's tree.
261
259
  child_env[WORKER_PROCESS_ENV] = "1"
@@ -263,17 +261,11 @@ module Samagotchi
263
261
  # A gem-mode worker activates the newest installed chi itself: under
264
262
  # `bundle exec` (a Gemfile that has samagotchi) Bundler's RUBYOPT would
265
263
  # hold it to the bundle's version, and its badge would never clear.
266
- child_env.merge!(InstalledGem.unbundled_env_changes) if InstalledGem.spec
264
+ child_env.merge!(ChildEnv.unbundled)
267
265
  opts[:env] = child_env unless child_env.empty?
268
266
  opts
269
267
  end
270
268
 
271
- GUARDRAILS_ENV_PREFIX = "SAMAGOTCHI_GUARDRAILS_"
272
- WORKER_ENV_PREFIX = "SAMAGOTCHI_WORKER_"
273
- # Tools::Builtins.parent_env's (Tools::TaskRuntime::MARKER_ENV_KEYS).
274
- SESSION_MARKER_ENV = %w[SAMAGOTCHI_PARENT_SESSION SAMAGOTCHI_SESSION_MODEL SAMAGOTCHI_SESSION_THINKING
275
- SAMAGOTCHI_SESSION_REASONING_HISTORY].freeze
276
-
277
269
  # List all sessions, reading status from persisted session.json files.
278
270
  # +project_root+: only that project's sessions (nil: every session).
279
271
  # +include_archived+: archived sessions too (Session#archived says which).
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+ require_relative "context_note"
5
+ require_relative "tool_output_cut"
6
+ require_relative "tools/task_wait"
7
+
8
+ module Samagotchi
9
+ # The note a finished background task leaves in its session (TaskWatch,
10
+ # the Worker's finish wake): a context note under its own header
11
+ # (ContextNote::TASK_HEADER), as Engine#add_context_note takes it. The
12
+ # wake variant opens a wake turn (turn_start, turn_id) and ends with the
13
+ # ask (WAKE_TEXT); the plain one joins the next turn (queue mode, a spent
14
+ # budget, a note-only task, the unmark after a failed wake).
15
+ module TaskFinishNote
16
+ WAKE_TEXT = "chi started this turn because a background task you started finished; your user didn't write " \
17
+ "anything.\nCarry on with what you planned to do with its result. If you planned nothing, tell your " \
18
+ "user in a sentence or two how it went."
19
+
20
+ module_function
21
+
22
+ # @param record [Tools::TaskRecord] the finished task
23
+ # @param limit_sec [Integer, nil] the limit an execute handed it over at
24
+ # @param wake [Boolean] the wake variant
25
+ # @param cap [Integer] max_tool_output_chars: task_wait's text is cut to
26
+ # it as ToolRunner cuts the tool's result
27
+ # @return [Hash] the note (ContextNote.message's keys)
28
+ def note(record, limit_sec:, wake:, cap:, turn_id: nil, now: Time.now)
29
+ note = { note_id: "task-#{record.id}", source: "task #{short_id(record.id)}", header: ContextNote::TASK_HEADER,
30
+ text: text(record, limit_sec: limit_sec, wake: wake, cap: cap), created_at: now.iso8601 }
31
+ wake ? note.merge(turn_start: true, turn_id: turn_id) : note
32
+ end
33
+
34
+ def text(record, limit_sec:, wake:, cap:)
35
+ [headline(record, limit_sec), "command: #{record.command}", body(record, cap), (WAKE_TEXT if wake)].compact.join("\n")
36
+ end
37
+
38
+ def headline(record, limit_sec)
39
+ seconds = duration(record)
40
+ outcome = record.exit_code.nil? ? record.status.to_s : "exit #{record.exit_code}"
41
+ handed = " (handed over by execute at its #{limit_sec}s limit)" if limit_sec
42
+ "Background task #{short_id(record.id)} finished: #{outcome}#{" after #{duration_text(seconds)}" if seconds}#{handed}."
43
+ end
44
+
45
+ # task_wait's own text for the task, cut to +cap+ as its result would be
46
+ # (ToolOutputCut's :head_tail_ref, naming the logs).
47
+ def body(record, cap)
48
+ text = Tools::TaskWait.finished_text(record).to_s
49
+ return text if text.length <= cap
50
+
51
+ ToolOutputCut.apply(text, cap: cap, tool: Tools::TaskWait::NAME, ref: record.output_path,
52
+ ref_stderr: record.stderr_path).first
53
+ end
54
+
55
+ # From started_at to the exit_code file's mtime (the shell's EXIT trap
56
+ # wrote it as it ended), else finished_at: that is when a refresh first
57
+ # noticed the end, which may be long after. nil when neither is known.
58
+ # @return [Float, nil] seconds
59
+ def duration(record)
60
+ started = time(record.started_at)
61
+ return nil unless started
62
+
63
+ ended = exit_code_mtime(record) || time(record.finished_at)
64
+ ended && [ended - started, 0].max
65
+ end
66
+
67
+ def duration_text(seconds)
68
+ seconds = seconds.round
69
+ return "#{seconds}s" if seconds < 60
70
+ return format("%dm%02ds", seconds / 60, seconds % 60) if seconds < 3600
71
+
72
+ format("%dh%02dm", seconds / 3600, (seconds % 3600) / 60)
73
+ end
74
+
75
+ def short_id(id) = Tools::TaskRecord.short_id(id)
76
+
77
+ def exit_code_mtime(record)
78
+ path = record.exit_code_path.to_s
79
+ path.empty? || !File.file?(path) ? nil : File.mtime(path)
80
+ rescue SystemCallError
81
+ nil
82
+ end
83
+
84
+ def time(value)
85
+ value && Time.iso8601(value.to_s)
86
+ rescue ArgumentError
87
+ nil
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,228 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "config"
4
+ require_relative "log"
5
+ require_relative "tools/task_runtime"
6
+
7
+ module Samagotchi
8
+ # The background tasks a worker's session started that the model hasn't
9
+ # seen finish (docs/internals/background-tasks.md, Finish wake). Held by
10
+ # the Worker (the --no-shared REPL has none) and fed by the Engine's raw
11
+ # :tool_call_completed events (#observe), the input Bridge#own_task_ids
12
+ # reads too; the idle loop takes the finished ones (#take_finished) for a
13
+ # wake turn or a plain note. In memory only: a crash loses them, and they
14
+ # behave as before (the model sees them at its next look).
15
+ #
16
+ # Thread-safe: the turn thread feeds it, the loop's thread takes from it,
17
+ # and anytime commands run on threads of their own.
18
+ class TaskWatch
19
+ # A watched task: +limit_sec+, the limit an execute handed it over at
20
+ # (nil for task_create's); +note_only+, the user stopped the turn that
21
+ # started or waited on it (M5): its finish is a plain note, no wake;
22
+ # +failures+, how often its note couldn't be written (#put_back) or the
23
+ # look for it failed (#look_failed).
24
+ Entry = Data.define(:id, :limit_sec, :note_only, :failures) do
25
+ def initialize(id:, limit_sec: nil, note_only: false, failures: 0) = super
26
+ end
27
+
28
+ # A task whose note or look failed this often still gets tried at each
29
+ # look, but no longer holds the worker up (#holding): a failure that
30
+ # never passes must not keep it alive for good.
31
+ MAX_FAILURES = 3
32
+
33
+ # The cancel reasons that are the user's own Stop (Bridge::CANCEL_REASONS
34
+ # less session_stopped, and the TUI's ctrl_c).
35
+ USER_CANCEL_REASONS = %i[manual user ctrl_c].freeze
36
+
37
+ # The first line of a task tool's result, after a gate-corrected call's
38
+ # "ran as: …" line and the optional "[tool]" lead.
39
+ TASK_LINE = /\A#{Tools::TaskRuntime::RAN_AS_LINE}(?:\[task_(?:wait|get|stop)\]\n)?task_id: (\S+)/
40
+ LOOK_TOOLS = %w[task_wait task_get task_stop].freeze
41
+ HANDED_OVER_LIMIT = /#{Regexp.escape(Tools::Execute::HANDED_OVER)} (\d+)s limit/
42
+
43
+ MODE_KEY = "session.task_wakes"
44
+ MODES = %w[wake queue off].freeze
45
+
46
+ # session.task_wakes: wake (default), queue or off; anything else is
47
+ # wake, and so is a config that can't be read (logged once per error
48
+ # class: it is read at every idle pass).
49
+ def self.mode
50
+ value = Config.get(MODE_KEY).to_s
51
+ MODES.include?(value) ? value : "wake"
52
+ rescue StandardError => e
53
+ @mode_errors ||= {}
54
+ unless @mode_errors[e.class]
55
+ @mode_errors[e.class] = true
56
+ Log.warn(:worker, "task_wakes_unreadable", error: e.class.name, msg: e.message)
57
+ end
58
+ "wake"
59
+ end
60
+
61
+ def initialize
62
+ @mutex = Mutex.new
63
+ @entries = {}
64
+ @turn_ids = []
65
+ end
66
+
67
+ # A raw Engine event: a result that started a task adds it, one that
68
+ # shows the model the task's end removes it (#seen_id).
69
+ def observe(event)
70
+ return unless event[:type] == :tool_call_completed
71
+
72
+ tool = event[:tool].to_s
73
+ output = event[:output].to_s
74
+ output = output.scrub unless output.valid_encoding?
75
+ if (id = Tools::TaskRuntime.created_id(tool, output))
76
+ add(id, limit_sec: tool == Tools::Execute::NAME ? output[HANDED_OVER_LIMIT, 1]&.to_i : nil)
77
+ elsif LOOK_TOOLS.include?(tool) && (id = output[TASK_LINE, 1])
78
+ touched(id)
79
+ remove(id) if seen?(tool, output)
80
+ end
81
+ end
82
+
83
+ def add(id, limit_sec: nil)
84
+ @mutex.synchronize do
85
+ @entries[id] = Entry.new(id: id, limit_sec: limit_sec)
86
+ @turn_ids << id
87
+ end
88
+ end
89
+
90
+ def remove(id)
91
+ @mutex.synchronize { @entries.delete(id) }
92
+ end
93
+
94
+ # A turn begins: the tasks it starts or waits on are its own (#turn_canceled).
95
+ def turn_started
96
+ @mutex.synchronize { @turn_ids = [] }
97
+ end
98
+
99
+ # A turn ended canceled for +reason+: the user's Stop makes that turn's
100
+ # tasks note-only (M5); a signal's, a hook's or a session stop's don't.
101
+ def turn_canceled(reason)
102
+ return unless USER_CANCEL_REASONS.include?(reason&.to_sym)
103
+
104
+ @mutex.synchronize do
105
+ @turn_ids.each { |id| @entries[id] &&= @entries[id].with(note_only: true) }
106
+ end
107
+ end
108
+
109
+ def ids = @mutex.synchronize { @entries.keys }
110
+
111
+ # The watched tasks, oldest first (a copy).
112
+ # @return [Array<Entry>]
113
+ def entries = @mutex.synchronize { @entries.values }
114
+
115
+ def any? = @mutex.synchronize { @entries.any? }
116
+
117
+ # The watched tasks that finished, taken out of the watch with their
118
+ # records ([Entry, TaskRecord], oldest first). One the user stopped (the
119
+ # web's stop-task button) or whose record is gone is dropped, not
120
+ # returned. +keep+: the ids the conversation still holds
121
+ # (TaskRuntime.created_ids_in): a !rollback or a restored turn may have
122
+ # dropped the result that added one, which is dropped too. One `ps` per
123
+ # running task (TaskRuntime.get_record); nothing with an empty watch.
124
+ # @return [Array<Array(Entry, Tools::TaskRecord)>]
125
+ def take_finished(keep: nil)
126
+ watched = entries
127
+ return [] if watched.empty?
128
+
129
+ watched.filter_map do |entry|
130
+ # Safe for any llm_context strategy: stale and forget stubs are drawn
131
+ # in the view sent to the model (LLMContextView, the entry's edits),
132
+ # never written over the saved tool_response text; only a !rollback
133
+ # or a restored turn takes the result that names the id out.
134
+ next drop(entry, log: "not_in_conversation") if keep && !keep.include?(entry.id)
135
+
136
+ record, error = Tools::TaskRuntime.get_record(entry.id)
137
+ if error || record.nil?
138
+ # Gone (its task dir deleted) or unreadable: no note can be written.
139
+ next drop(entry, log: "record_unreadable", level: :info, error: error.to_s.lines.first&.strip)
140
+ end
141
+ next nil if record.running?
142
+ # Removed by a look meanwhile (a task_wait on an anytime command's thread).
143
+ next nil unless drop(entry, kept: true)
144
+ next nil if record.stopped_by_user?
145
+
146
+ [entry, record]
147
+ end
148
+ end
149
+
150
+ # +entries+ taken by #take_finished whose note couldn't be written:
151
+ # watched again, the failure counted (an id watched meanwhile keeps its
152
+ # own entry).
153
+ def put_back(entries)
154
+ @mutex.synchronize do
155
+ entries.each { |entry| @entries[entry.id] ||= entry.with(failures: entry.failures + 1) }
156
+ end
157
+ end
158
+
159
+ # The look for finished tasks failed before it could tell which ended
160
+ # (the conversation's task ids couldn't be read): every watched task
161
+ # counts a failure.
162
+ def look_failed
163
+ @mutex.synchronize { @entries.transform_values! { |entry| entry.with(failures: entry.failures + 1) } }
164
+ end
165
+
166
+ # Whether a watched task has finished (#take_finished would take it):
167
+ # Engine#next_turn_waiting asks at a turn's end. Takes nothing. A record
168
+ # that can't be read counts as not finished here (and not running in
169
+ # #running): #take_finished drops it at the next look, logged.
170
+ def finished?
171
+ ids.any? do |id|
172
+ record, = Tools::TaskRuntime.get_record(id)
173
+ record && !record.running?
174
+ end
175
+ end
176
+
177
+ # The watched tasks a finish would wake for, running or finished and
178
+ # not yet taken (#take_finished takes them at the idle loop's next
179
+ # look): they hold the worker's idle exit (WorkerIdleExit's
180
+ # watched_tasks). Not a note-only one (no wake would come for it), nor
181
+ # one whose note or look failed MAX_FAILURES times. No `ps`: in memory.
182
+ # @return [Array<Entry>]
183
+ def holding = entries.reject { |entry| entry.note_only || entry.failures >= MAX_FAILURES }
184
+
185
+ # The #holding tasks still running ({id:, command:}), for /exit
186
+ # (WorkerIdleExit's watched_running): one finished meanwhile is noted
187
+ # as the worker leaves. One `ps` per task.
188
+ # @return [Array<Hash>]
189
+ def running
190
+ holding.filter_map do |entry|
191
+ record, = Tools::TaskRuntime.get_record(entry.id)
192
+ { id: entry.id, command: record.command.to_s } if record&.running?
193
+ end
194
+ end
195
+
196
+ private
197
+
198
+ # Removes +entry+; nil (a filter_map's skip), or with +kept+ whether it
199
+ # was there. +log+: why a task leaves without a note, at +level+ (debug
200
+ # by default), with +error+ when there is one.
201
+ def drop(entry, kept: false, log: nil, level: :debug, error: nil)
202
+ removed = !remove(entry.id).nil?
203
+ if log && removed
204
+ Log.public_send(level, :worker, "task_watch_dropped", **{ task_id: entry.id, reason: log, error: error }.compact)
205
+ end
206
+ kept ? removed : nil
207
+ end
208
+
209
+ def touched(id)
210
+ @mutex.synchronize { @turn_ids << id unless @turn_ids.include?(id) }
211
+ end
212
+
213
+ # Whether +tool+'s +output+ for a task shows the model its end:
214
+ # task_wait's final status or a done_pattern match (D5), task_get's
215
+ # final status (the status alone is enough), any task_stop.
216
+ def seen?(tool, output)
217
+ status = output[/^status: (\S+)/, 1]
218
+ case tool
219
+ when "task_stop" then true
220
+ when "task_get" then final?(status)
221
+ when "task_wait" then final?(status) || output.match?(/^wait_result: pattern_matched$/)
222
+ else false
223
+ end
224
+ end
225
+
226
+ def final?(status) = !status.nil? && status != "running"
227
+ end
228
+ end
@@ -60,8 +60,12 @@ module Samagotchi
60
60
  HELD_REASONS = {
61
61
  "turn_running" => "a turn is running", "input_queued" => "prompts are queued",
62
62
  "continue_offered" => "a continue offer is pending", "client_connected" => "another UI is attached",
63
- "reminders" => "reminders are set", "starting" => "the worker is still starting"
63
+ "reminders" => "reminders are set", "starting" => "the worker is still starting",
64
+ "background_tasks" => "background tasks it started are still running"
64
65
  }.freeze
66
+ # How to end a background task that holds the worker up (an older
67
+ # worker's 409 has no hint of its own).
68
+ TASK_STOP_HINT = "stop one with the web's stop-task button or by asking for task_stop"
65
69
  # How much of the last answer a join shows.
66
70
  JOIN_ANSWER_LINES = 12
67
71
  JOIN_ANSWER_CHARS = 1200
@@ -556,7 +560,8 @@ module Samagotchi
556
560
  "Detached; the session's worker is stopping. Resume with: chi --resume #{id}"
557
561
  when 409
558
562
  reason = reply.json&.fetch("reason", nil).to_s
559
- "Detached; the session keeps running (#{HELD_REASONS.fetch(reason, reason)}). Re-attach with: chi --attach #{id}"
563
+ "Detached; the session keeps running (#{held_words(reason, reply.json&.fetch("tasks", nil))}). " \
564
+ "Re-attach with: chi --attach #{id}"
560
565
  when 0 then exit_failed_line("no reply")
561
566
  when 408 then exit_failed_line(LATE)
562
567
  else
@@ -567,6 +572,14 @@ module Samagotchi
567
572
  end
568
573
  end
569
574
 
575
+ # Why the worker stayed up; the background tasks it waits for by
576
+ # their short id and command (a finish wake's, D3), and how to stop one.
577
+ def held_words(reason, tasks)
578
+ words = HELD_REASONS.fetch(reason, reason)
579
+ listed = Array(tasks).filter_map { |t| "#{Tools::TaskRecord.short_id(t["id"])} (#{t["command"]})" if t.is_a?(Hash) }
580
+ listed.empty? ? words : "#{words}: #{listed.join(", ")}; #{TASK_STOP_HINT}"
581
+ end
582
+
570
583
  # An older worker leaves the field out.
571
584
  def discards?(reply) = reply.json&.fetch("discard", nil) == true
572
585
 
@@ -1628,8 +1641,10 @@ module Samagotchi
1628
1641
  def reminder_origin?(origin) = origin[:client_id].to_s.start_with?(ClientId::SYSTEM_PREFIX)
1629
1642
  def report_origin?(origin) = origin[:client_id].to_s.start_with?(ClientId::CHILD_PREFIX)
1630
1643
  def context_origin?(origin) = origin[:client_id].to_s.start_with?(ClientId::CONTEXT_PREFIX)
1631
- # A turn with no prompt: "(continuing)"; a context wake's label alone.
1632
- def continuing_text(origin) = context_origin?(origin) ? nil : "(continuing)"
1644
+ def task_origin?(origin) = origin[:client_id].to_s.start_with?(ClientId::TASK_PREFIX)
1645
+ # A turn with no prompt: "(continuing)"; a context wake's or a task
1646
+ # finish wake's label alone.
1647
+ def continuing_text(origin) = context_origin?(origin) || task_origin?(origin) ? nil : "(continuing)"
1633
1648
  end
1634
1649
  end
1635
1650
  end
@@ -9,6 +9,7 @@ require_relative "../steer_cut"
9
9
  require_relative "../memory_bundle/index_size"
10
10
  require_relative "../prompt_note"
11
11
  require_relative "../prompt_files"
12
+ require_relative "../tools/task_record"
12
13
  require_relative "model_segments_text"
13
14
 
14
15
  module Samagotchi
@@ -207,8 +208,9 @@ module Samagotchi
207
208
 
208
209
  # Who sent a line, by its client_id: CLIENT_LABELS by the whole id or
209
210
  # its prefix, a turn an attached context source started
210
- # (context:<name>) as "context <name> changed", AUTOMATIC_LABEL for
211
- # an id chi doesn't know; +fallback+ for none (the user's own).
211
+ # (context:<name>) as "context <name> changed", a finished background
212
+ # task's (task:<id>) as "task <id8> finished", AUTOMATIC_LABEL for an
213
+ # id chi doesn't know; +fallback+ for none (the user's own).
212
214
  def client_label(client_id, fallback = "user")
213
215
  return fallback unless client_id
214
216
 
@@ -216,6 +218,9 @@ module Samagotchi
216
218
  name = id.delete_prefix(ClientId::CONTEXT_PREFIX)
217
219
  return "context #{name} changed" if id.start_with?(ClientId::CONTEXT_PREFIX) && !name.empty?
218
220
 
221
+ task = id.delete_prefix(ClientId::TASK_PREFIX)
222
+ return "task #{Tools::TaskRecord.short_id(task)} finished" if id.start_with?(ClientId::TASK_PREFIX) && !task.empty?
223
+
219
224
  prefix = id[/\A[^:]*:/]
220
225
  CLIENT_LABELS[id] || (prefix && CLIENT_LABELS[prefix]) || AUTOMATIC_LABEL
221
226
  end