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
@@ -35,10 +35,10 @@ import { createReasoningHistoryControl } from "./reasoning_history_chip.js";
35
35
  import { renderInfoBar } from "./info_bar.js";
36
36
  import { sessionBar, workerBadgeFor } from "./info_bar_model.js";
37
37
  import { createShownTexts } from "./shown_texts.js";
38
- import { MODEL_KEY } from "./model_pick.js";
38
+ import { MODEL_KEY, modelsFailedText } from "./model_pick.js";
39
39
  import { emptySessionState, sessionStateFrom } from "./session_state.js";
40
40
  import { createModelPicker } from "./model_picker.js";
41
- import { admitImages, chipName, chipsTemplate, imageFiles, restoredChips, thumbsTemplate, turnRefs, turnText, uploadChips } from "./images.js";
41
+ import { admitImages, chipName, chipsAfterSend, chipsTemplate, imageFiles, restoredChips, thumbsTemplate, turnRefs, turnText, uploadChips } from "./images.js";
42
42
  import { normalize, firstPreview, failedTurnText, deleteConfirmText, goneSessionNotice, cantText, withLiveStatus, withoutWorker } from "./format.js";
43
43
  import { createTurnView, turnHistoryNodes } from "./turn_view.js";
44
44
  import { elementOf, emptyAnswerTemplate } from "./turn_html.js";
@@ -992,7 +992,10 @@ async function readTurnEnd() {
992
992
  lists.render();
993
993
  lists.paintActive();
994
994
  }
995
- } catch(_){}
995
+ } catch (_) {
996
+ // A failed read (the server or worker gone a moment) leaves the answer
997
+ // as the stream drew it, plain text: the session's next load renders it.
998
+ }
996
999
  }
997
1000
 
998
1001
  function seedSeenFromDom() {
@@ -1452,8 +1455,9 @@ async function handleSendTurn({ delivery: chosen = null } = {}) {
1452
1455
  if (restored?.refill && sentTo === selected) openForVisit();
1453
1456
  promptEl.value = composerAfterAck({ sent: line, current: promptEl.value, refill: restored?.refill || "" });
1454
1457
  fitPrompt();
1455
- clearChips();
1456
- if (restored?.images?.length) setChips(restoredChips(sentTo, restored.images));
1458
+ // Only the sent chips go: images added since stay (they go up at their own send).
1459
+ const kept = chipsAfterSend(chips, sending);
1460
+ setChips(restored?.images?.length ? kept.concat(restoredChips(sentTo, restored.images)) : kept);
1457
1461
  // Its turn_enqueued usually tagged the echo already; the ack covers a
1458
1462
  // worker that wasn't live (no turn_enqueued then).
1459
1463
  if (ack?.enqueued_id && !echo.dataset.enqueuedId && !bubbleById(ack.enqueued_id)) {
@@ -1548,7 +1552,7 @@ async function handleCancel() {
1548
1552
  $("#newBtn").addEventListener("click", () => {
1549
1553
  if (allViewOpen()) leaveAllView("");
1550
1554
  clearSelection();
1551
- loadModels();
1555
+ loadModels({ asked: true });
1552
1556
  });
1553
1557
 
1554
1558
  // ── The start page's model picker ──────────────────────────────────────────
@@ -1641,11 +1645,19 @@ function restoreStartPick() {
1641
1645
  renderNewChatLlmContext();
1642
1646
  renderNewChatThinking();
1643
1647
  }
1644
- async function loadModels() {
1648
+ // A reload cuts the fetch: its error is no news (modelsFailedText).
1649
+ let pageLeaving = false;
1650
+ addEventListener("beforeunload", () => { pageLeaving = true; });
1651
+ addEventListener("pagehide", () => { pageLeaving = true; });
1652
+ addEventListener("pageshow", () => { pageLeaving = false; }); // back from the bfcache
1653
+ // +asked+: + new session's load, for the start page it opens.
1654
+ async function loadModels({ asked = false } = {}) {
1645
1655
  let payload;
1646
1656
  try {
1647
1657
  payload = await listModels();
1648
- } catch (_) {
1658
+ } catch (e) {
1659
+ const text = modelsFailedText(e, { startPage: !selected, asked, leaving: pageLeaving });
1660
+ if (text) showToast(text);
1649
1661
  return;
1650
1662
  }
1651
1663
  const stored = readStored(MODEL_KEY);
@@ -1678,16 +1690,13 @@ function renderChips() {
1678
1690
  drawFoldControls();
1679
1691
  }
1680
1692
 
1693
+ // A chip that goes frees its preview; one kept in +list+ keeps it.
1681
1694
  function setChips(list) {
1682
- for (const c of chips) if (c.file && c.src) URL.revokeObjectURL(c.src);
1695
+ for (const c of chips) if (c.file && c.src && !list.includes(c)) URL.revokeObjectURL(c.src);
1683
1696
  chips = list;
1684
1697
  renderChips();
1685
1698
  }
1686
1699
 
1687
- function clearChips() {
1688
- setChips([]);
1689
- }
1690
-
1691
1700
  // A paste's, a drop's or the image button's files: the images the message
1692
1701
  // may still take become chips (images.js admitImages); a toast says why the
1693
1702
  // rest stayed out. Whether any went in.
@@ -1806,7 +1815,8 @@ composerEl.addEventListener("drop", (e) => {
1806
1815
  const unqueue = createUnqueue({
1807
1816
  sessionId: () => selected,
1808
1817
  clientId,
1809
- toast: (text) => showToast(text),
1818
+ toast: (text, action, options) => showToast(text, action, options),
1819
+ copy: (text) => copyText(text),
1810
1820
  // Edit: the removed text back into an empty composer (never over a draft).
1811
1821
  putBack: (text) => {
1812
1822
  if (promptEl.value.trim() || chips.length) return false;
@@ -150,7 +150,10 @@ export function createCardsPlace({
150
150
  const el = card.type === "card" && cardElement(card.id);
151
151
  if (el) refreshCardBody(el, card);
152
152
  }
153
- } catch (_) {}
153
+ } catch (_) {
154
+ // The live text stays (a failed re-read is only a lost render);
155
+ // the next card or load renders it.
156
+ }
154
157
  }, 150);
155
158
  }
156
159
 
@@ -94,6 +94,12 @@ export function chipsTemplate(chips) {
94
94
  html`<span class="chip" data-index=${i}><img src=${c.src} alt=""><span class="chip-name">${c.name}</span><button type="button" class="chip-remove" data-index=${i} aria-label=${`remove ${c.name}`}>×</button></span>`);
95
95
  }
96
96
 
97
+ // The chips left once a send is acked: those added while it was in flight
98
+ // (an upload can take seconds) stay for the next message.
99
+ export function chipsAfterSend(chips, sent) {
100
+ return (chips || []).filter((c) => !sent.includes(c));
101
+ }
102
+
97
103
  // The {file, name} refs a turn names.
98
104
  export function turnRefs(chips) {
99
105
  return (chips || []).filter((c) => c.ref).map((c) => ({ file: c.ref.file, name: c.name }));
@@ -1,7 +1,17 @@
1
1
  // The start page's model picker: pure helpers over GET /api/models' payload.
2
+ import { cantText } from "./format.js";
2
3
 
3
4
  export const MODEL_KEY = "chi_model";
4
5
 
6
+ // A failed GET /api/models' toast: the picker stays hidden, so it says why
7
+ // and what a new session gets (+ new session asks again). "" (no toast) in
8
+ // a session, where no picker shows (+asked+: + new session's own load),
9
+ // and for a fetch aborted or cut by the page unloading.
10
+ export function modelsFailedText(error, { startPage = true, asked = false, leaving = false } = {}) {
11
+ if (leaving || error?.name === "AbortError" || !(startPage || asked)) return "";
12
+ return `${cantText("list the models", error)}. A new session starts on the default model.`;
13
+ }
14
+
5
15
  // The name to preselect from the rows (modelRows): the remembered one when
6
16
  // it is still offered (any case), else the server's default, both even
7
17
  // when their host is down; else the first row whose host answered, else
@@ -15,11 +15,11 @@ export function notifyState({ hasNotification, secureContext, permission, wanted
15
15
  }
16
16
 
17
17
  // A finished turn shorter than this was watched: no "done". Not a turn
18
- // chi ran for a delegate child's report (last_turn origin delegate_report)
19
- // or an attached context change (context): nobody started it, so nobody
20
- // watched it.
18
+ // chi ran for a delegate child's report (last_turn origin delegate_report),
19
+ // an attached context change (context) or a finished background task
20
+ // (task): nobody started it, so nobody watched it.
21
21
  const DONE_MIN_SECONDS = 10;
22
- const UNWATCHED_ORIGINS = ["delegate_report", "context"];
22
+ const UNWATCHED_ORIGINS = ["delegate_report", "context", "task"];
23
23
 
24
24
  // The step-limit question (kind "continue": a turn ran out of iterations
25
25
  // and asks whether to go on) has its own reason: "out of steps".
@@ -36,19 +36,26 @@ export function createToasts({ el, onChange = () => {}, doc = globalThis.documen
36
36
  entry.el);
37
37
  }
38
38
 
39
+ function item(text, action, sticky) {
40
+ const entry = { el: doc.createElement("div"), timer: null, sticky, action, key: null };
41
+ entry.el.className = "toast-item";
42
+ draw(entry, text, action, sticky);
43
+ entry.key = entry.el.querySelector(".toast-message").textContent;
44
+ return entry;
45
+ }
46
+
39
47
  function show(text, action = null, { sticky = false } = {}) {
40
- const candidate = { el: doc.createElement("div"), timer: null, sticky, action, key: null };
41
- candidate.el.className = "toast-item";
42
- draw(candidate, text, action, sticky);
43
- candidate.key = candidate.el.querySelector(".toast-message").textContent;
48
+ // Words are their own key; a template's key is its drawn text, so only
49
+ // a template is drawn before the look-up.
50
+ const candidate = typeof text === "string" ? null : item(text, action, sticky);
51
+ const key = candidate ? candidate.key : text;
44
52
 
45
- const duplicate = entries.find((entry) => entry.key === candidate.key);
53
+ const duplicate = entries.find((entry) => entry.key === key);
46
54
  if (duplicate) {
47
55
  if (duplicate.timer !== null) clearTimeout(duplicate.timer);
48
56
  duplicate.timer = null;
49
57
  duplicate.sticky = sticky || duplicate.sticky;
50
58
  duplicate.action = action || duplicate.action;
51
- duplicate.key = candidate.key;
52
59
  draw(duplicate, text, duplicate.action, duplicate.sticky);
53
60
  entries.push(...entries.splice(entries.indexOf(duplicate), 1));
54
61
  el.appendChild(duplicate.el);
@@ -60,9 +67,10 @@ export function createToasts({ el, onChange = () => {}, doc = globalThis.documen
60
67
  if (entries.length === MAX_TOASTS) {
61
68
  remove(entries.find((entry) => !entry.sticky) || entries[0], false);
62
69
  }
63
- entries.push(candidate);
64
- el.appendChild(candidate.el);
65
- if (!sticky) candidate.timer = setTimeout(() => remove(candidate), TOAST_MS);
70
+ const entry = candidate || item(text, action, sticky);
71
+ entries.push(entry);
72
+ el.appendChild(entry.el);
73
+ if (!sticky) entry.timer = setTimeout(() => remove(entry), TOAST_MS);
66
74
  sync();
67
75
  }
68
76
 
@@ -107,13 +107,25 @@ export function isReportClient(clientId) {
107
107
 
108
108
  // A turn chi ran because an attached context source changed: context:<name>.
109
109
  const CONTEXT_CLIENT = "context:";
110
+ // A turn chi ran because a background task the session started finished
111
+ // unseen: task:<task id> (the Worker's finish wake).
112
+ const TASK_CLIENT = "task:";
113
+
114
+ // A task id as people read it: the random part a task_create id ends with
115
+ // (it starts with the time), else its first 8 chars (TaskRecord.short_id).
116
+ function shortTaskId(id) {
117
+ const m = /-([0-9a-f]{8})$/i.exec(id);
118
+ return m ? m[1] : id.slice(0, 8);
119
+ }
110
120
 
111
121
  // The label on another client's prompt, by its client_id prefix; a context
112
- // change's turn as "context <name> changed".
122
+ // change's turn as "context <name> changed", a finished task's as
123
+ // "task <id8> finished".
113
124
  export function clientLabel(clientId) {
114
125
  if (!clientId) return null;
115
126
  const id = String(clientId);
116
127
  if (id.startsWith(CONTEXT_CLIENT) && id.length > CONTEXT_CLIENT.length) return `context ${id.slice(CONTEXT_CLIENT.length)} changed`;
128
+ if (id.startsWith(TASK_CLIENT) && id.length > TASK_CLIENT.length) return `task ${shortTaskId(id.slice(TASK_CLIENT.length))} finished`;
117
129
  const colon = id.indexOf(":");
118
130
  return CLIENT_LABELS[id] || (colon >= 0 && CLIENT_LABELS[id.slice(0, colon + 1)]) || AUTOMATIC_LABEL;
119
131
  }
@@ -4,8 +4,9 @@
4
4
  // goes with the turn_dequeued every tab gets (bubbles.js op "remove"), and
5
5
  // here at the 200 already. Its text comes back (edit): into an empty
6
6
  // composer, to change and send again; over a draft only in a toast (the
7
- // draft stays). Its images don't come back. A refusal keeps it and says why
8
- // in a toast. No confirm: nothing is lost. Pure but for the
7
+ // draft stays), and after a switch to another session meanwhile only in a
8
+ // sticky toast naming its session, with Copy. Its images don't come back. A refusal keeps it
9
+ // and says why in a toast. No confirm: nothing is lost. Pure but for the
9
10
  // injected calls, so `node --test` covers it; app.js binds onClick to the
10
11
  // turn roots.
11
12
  import { dequeueTurn as postDequeue } from "./data.js";
@@ -28,8 +29,9 @@ function clipped(text) {
28
29
 
29
30
  // +sessionId+ is read at each click (the open session); +clientId+ is this
30
31
  // tab's (the turn_dequeued names who removed it); +putBack(text)+ fills the
31
- // composer when it is empty and says whether it did.
32
- export function createUnqueue({ sessionId, clientId = null, dequeue = postDequeue, toast = () => {}, putBack = () => false }) {
32
+ // composer when it is empty and says whether it did; +copy(text)+ says
33
+ // whether the clipboard took it; +toast(text, action, options)+ as showToast.
34
+ export function createUnqueue({ sessionId, clientId = null, dequeue = postDequeue, toast = () => {}, putBack = () => false, copy = null }) {
33
35
  // A click on a queued bubble's ×; any other click is left alone.
34
36
  async function onClick(e) {
35
37
  const button = e.target?.closest?.("button.unqueue");
@@ -44,6 +46,12 @@ export function createUnqueue({ sessionId, clientId = null, dequeue = postDequeu
44
46
  const result = await dequeue(id, enqueuedId, { clientId });
45
47
  removeQueuedBubble(bubble);
46
48
  const text = String(result?.prompt || "");
49
+ // Another session opened meanwhile: its composer isn't where this
50
+ // text goes, so only the toast has it.
51
+ if (text.trim() && sessionId() !== id) {
52
+ movedOn(id, text);
53
+ return result;
54
+ }
47
55
  if (text.trim() && !putBack(text)) toast(`Removed (your draft stays): ${clipped(text)}`);
48
56
  return result;
49
57
  } catch (error) {
@@ -52,5 +60,13 @@ export function createUnqueue({ sessionId, clientId = null, dequeue = postDequeu
52
60
  return null;
53
61
  }
54
62
  }
63
+ // The removed text after a switch: in a sticky toast, its Copy takes the
64
+ // whole text (the toast clips it); a failed copy shows the toast again.
65
+ function movedOn(id, text) {
66
+ const line = `Removed from ${id.slice(0, 8)}: ${clipped(text)}`;
67
+ const show = () => toast(line, copy ? action : null, { sticky: true });
68
+ const action = { label: "Copy", run: async () => { if (await copy(text)) toast("Copied"); else show(); } };
69
+ show();
70
+ }
55
71
  return { onClick };
56
72
  }
@@ -15,6 +15,7 @@ require_relative "worker_idle_exit"
15
15
  require_relative "worker_wakes"
16
16
  require_relative "worker_inbound"
17
17
  require_relative "worker_signals"
18
+ require_relative "worker_task_wakes"
18
19
  require_relative "session_manager"
19
20
  require_relative "archive_store"
20
21
  require_relative "log"
@@ -31,6 +32,7 @@ require_relative "child_reports"
31
32
  require_relative "child_move"
32
33
  require_relative "context_absorber"
33
34
  require_relative "context_poller"
35
+ require_relative "task_watch"
34
36
  require_relative "tools/task_runtime"
35
37
 
36
38
  module Samagotchi
@@ -74,6 +76,8 @@ module Samagotchi
74
76
  # How much of a command's output goes into its :command_ran.
75
77
  COMMAND_OUTPUT_LIMIT = 4096
76
78
  BUSY_OUTPUT = "busy: wait for the turn to end"
79
+ # /exit held by background tasks a finish wake waits for: how to end them.
80
+ TASK_STOP_HINT = "stop one with the web's stop-task button or by asking for task_stop"
77
81
  # !rollback mid-turn would mean "the turn I'm in".
78
82
  ROLLBACK_BUSY_OUTPUT = "busy: Ctrl-C the turn first, then !rollback"
79
83
  # How long a signalled worker's turn has to end (it is canceled at once,
@@ -242,7 +246,7 @@ module Samagotchi
242
246
  # a command queued mid-turn (/thinking, /model, /llm-context) changes
243
247
  # what it starts with: no turn-end warm-up then.
244
248
  @engine.next_turn_waiting = lambda {
245
- !@command_queue.empty? || SessionInbox.find_new_input_files(@session_dir).any? || wake_due?
249
+ !@command_queue.empty? || SessionInbox.find_new_input_files(@session_dir).any? || wake_due? || @task_wakes.due?
246
250
  }
247
251
  @turn_flow = TurnFlow.new(engine: @engine)
248
252
  # Its question's answer is queued as the matching /continue (the
@@ -258,6 +262,12 @@ module Samagotchi
258
262
  # The seq of the last turn's end event: a command queued before it was
259
263
  # queued while that turn ran.
260
264
  @turn_end_seq = 0
265
+ # The background tasks this session started and hasn't seen finish:
266
+ # one that finishes unseen wakes the idle session (#run_task_wake).
267
+ @task_wakes = WorkerTaskWakes.new(engine: @engine, session: @session, wakes: @wakes,
268
+ awaiting_continue: -> { @turn_flow.awaiting_continue? },
269
+ idle: -> { nothing_queued? && !@exit_requested },
270
+ save: -> { save_or_log(:task_notes) { save_session } })
261
271
  @engine.subscribe(observer: lambda { |event|
262
272
  if Events::TURN_END.include?(event[:type])
263
273
  @turn_end_seq = event[:event_seq]
@@ -269,6 +279,7 @@ module Samagotchi
269
279
  @turn_ending = false
270
280
  end
271
281
  ring_question(event) if event[:type] == :question_requested
282
+ @task_wakes.observe(event)
272
283
  })
273
284
  # This session's own delegate children's news (rings in children/).
274
285
  @child_reports = ChildReports.new(session_id: @session_id, state_dir: @state_dir,
@@ -315,7 +326,13 @@ module Samagotchi
315
326
  timeout_minutes: @idle_exit_minutes || SessionManager.config_idle_exit_minutes,
316
327
  input_pending: -> { !@command_queue.empty? || !SessionInbox.find_new_input_files(@session_dir).empty? },
317
328
  awaiting_continue: -> { @turn_flow.awaiting_continue? },
318
- running_tasks: -> { Tools::TaskRuntime.running_created_in(@engine.messages_checkpoint) }
329
+ running_tasks: -> { Tools::TaskRuntime.running_created_in(@engine.messages_checkpoint) },
330
+ # Only when a finish would wake the session (D3): with queue its end
331
+ # is just a note, not worth keeping the worker up for. One that
332
+ # finished since the pass looked holds too, until the next pass
333
+ # wakes for it (#run_task_wake).
334
+ watched_tasks: -> { TaskWatch.mode == "wake" ? @task_wakes.watch.holding : [] },
335
+ watched_running: -> { TaskWatch.mode == "wake" ? @task_wakes.watch.running : [] }
319
336
  )
320
337
  end
321
338
 
@@ -371,12 +388,13 @@ module Samagotchi
371
388
  run_input_files(input_files)
372
389
  end
373
390
 
374
- # Nothing queued: a delegate wake turn or a due reminder runs, else
375
- # the worker leaves when the idle exit or a client's exit is due, else
376
- # it sleeps until woken or the fallback tick.
391
+ # Nothing queued: a delegate wake turn, a task finish wake turn or a due
392
+ # reminder runs, else the worker leaves when the idle exit or a client's
393
+ # exit is due, else it sleeps until woken or the fallback tick.
377
394
  # @return [Symbol, nil] as #pass
378
395
  def idle_pass
379
396
  return if run_wake_turn
397
+ return if run_task_wake
380
398
  return if run_due_reminders
381
399
  return left(:idle_exit) if @idle_exit.due? && leave_idle
382
400
  return left(@exit_restart ? :restart : :exit_requested) if @exit_requested && leave_on_request
@@ -441,6 +459,9 @@ module Samagotchi
441
459
  memories: @session.preloaded_memory_names,
442
460
  muted_memories: @session.muted_memory_names,
443
461
  appended_prompt_files: @session.appended_prompt_files,
462
+ # The finish wake reports a task the model leaves running: the tool
463
+ # descriptions say so, fixed for this worker (a stable prompt prefix).
464
+ task_finish_notes: TaskWatch.mode != "off",
444
465
  reminders: {
445
466
  callback: lambda { |due_names|
446
467
  # A reminder is due: the loop runs a reminder turn for it once
@@ -514,26 +535,14 @@ module Samagotchi
514
535
  # A turn nobody typed for a changed source (a context wake): a continue
515
536
  # turn from context:<name> whose first message is the wake note already
516
537
  # in the conversation (named by +turn_id+), on the same rules as a
517
- # delegate report's wake turn: it counts toward session.max_wakes (a
518
- # peer's answer toward context.peer_turns too, unless it agrees), and a failure goes back
519
- # to before the turn (the note stays, as a plain one:
520
- # WorkerInbound#unmark_wake) and pauses wakes until a human's input.
538
+ # delegate report's wake turn (#run_wake): it counts toward
539
+ # session.max_wakes (a peer's answer toward context.peer_turns too,
540
+ # unless it agrees).
521
541
  # +context_wake+: a WorkerInbound::ContextWake.
522
542
  def run_context_wake_turn(context_wake)
523
543
  name = context_wake.name
524
544
  Log.info(:worker, "context_wake_turn", name: name, in_a_row: @wakes.count!(peer: context_wake.peer_turn?))
525
- @turn_flow.before_prompt_turn
526
- run_engine_turn(nil, continue: true, origin: context_wake.origin,
527
- id: context_wake.turn_id, max_iterations: IterationLimit.for) do |result, error|
528
- if error
529
- kept = @turn_flow.prompt_turn_failed(error: error, wake: "the change in attached context #{name}") == :kept
530
- # A turn that got somewhere stays, its wake mark with it.
531
- @inbound.unmark_wake(context_wake) unless kept
532
- @wakes.pause!
533
- else
534
- @continue_offer.after_turn(result)
535
- end
536
- end
545
+ run_wake(origin: context_wake.origin, failed: "the change in attached context #{name}", mark: context_wake)
537
546
  end
538
547
 
539
548
  # The first prompt (spawn_session's): a delegate child's task, which is
@@ -552,8 +561,10 @@ module Samagotchi
552
561
  # @param prompt [WorkerInbound::Prompt]
553
562
  def run_prompt(prompt)
554
563
  origin = prompt.origin
555
- # A first turn sees the context attached before it.
564
+ # A first turn sees the context attached before it, and any turn the
565
+ # background tasks that finished since the last look.
556
566
  absorb_context(before_turn: true)
567
+ @task_wakes.absorb
557
568
  user_input(origin&.dig(:client_id))
558
569
  @continue_offer.drop(origin)
559
570
  @turn_flow.before_prompt_turn
@@ -621,30 +632,68 @@ module Samagotchi
621
632
 
622
633
  @wakes.hand_over(reports)
623
634
  Log.info(:worker, "delegate_wake_turn", reports: reports.size, in_a_row: @wakes.count!)
635
+ # Rolled back, the rings stay (settle_child_reports): the user's next
636
+ # turn takes them. A turn that got somewhere stays, and read them.
637
+ run_wake(origin: reports.first.origin,
638
+ failed: reports.size == 1 ? "a delegate's report" : "#{reports.size} delegate reports",
639
+ kept: "chi keeps #{reports.size == 1 ? "the report and brings it" : "the reports and brings them"} " \
640
+ "again with the user's next message.")
641
+ true
642
+ ensure
643
+ @wakes.drop_handed
644
+ end
645
+
646
+ # A wake turn (a delegate report's, an attached context change's, a
647
+ # finished task's): a continue turn nobody typed, from +origin+. A
648
+ # failure goes back to before the turn and pauses wakes until a human's
649
+ # input (a provider outage doesn't loop); so does a Stop before it got
650
+ # anywhere (a Stop or a before_turn hook's cancel before the model
651
+ # call, restored by TurnFlow), rather than waking again at once. Either
652
+ # way +mark+ (a WorkerInbound::ContextWake or TaskWake: its wake note,
653
+ # the turn's first message) goes back to a plain note
654
+ # (WorkerInbound#unmark_wake), unless the failed turn kept its steps.
655
+ # +failed+ names the wake in the failed turn's note, +kept+ says what
656
+ # happens to it (TurnFlow#prompt_turn_failed).
657
+ def run_wake(origin:, failed:, kept: nil, mark: nil)
624
658
  @turn_flow.before_prompt_turn
625
659
  restores = @turn_flow.restores
626
- run_engine_turn(nil, continue: true, origin: reports.first.origin, max_iterations: IterationLimit.for) do |result, error|
660
+ run_engine_turn(nil, continue: true, origin: origin, id: mark&.turn_id,
661
+ max_iterations: IterationLimit.for) do |result, error|
627
662
  if error
628
- # Rolled back, the rings stay (settle_child_reports): the user's
629
- # next turn takes them. A turn that got somewhere stays, and read them.
630
- @turn_flow.prompt_turn_failed(
631
- error: error, wake: reports.size == 1 ? "a delegate's report" : "#{reports.size} delegate reports",
632
- wake_kept: "chi keeps #{reports.size == 1 ? "the report and brings it" : "the reports and brings them"} " \
633
- "again with the user's next message."
634
- )
663
+ steps_kept = @turn_flow.prompt_turn_failed(error: error, wake: failed, wake_kept: kept) == :kept
664
+ @inbound.unmark_wake(mark) if mark && !steps_kept
635
665
  @wakes.pause!
636
666
  else
637
667
  @continue_offer.after_turn(result)
638
- # Stopped before it got anywhere (a Stop or a before_turn hook's
639
- # cancel before the model call) and restored: its rings stay, and
640
- # wait for the user's next message as after a failure, rather
641
- # than waking again at once.
642
- @wakes.pause! if result.canceled? && @turn_flow.restores != restores
668
+ if result.canceled? && @turn_flow.restores != restores
669
+ @inbound.unmark_wake(mark) if mark
670
+ @wakes.pause!
671
+ end
643
672
  end
644
673
  end
674
+ end
675
+
676
+ # The background tasks this session started that finished unseen, as
677
+ # notes (WorkerTaskWakes#take), and the wake turn the first one may
678
+ # start.
679
+ # @return [Boolean] whether a wake turn ran
680
+ def run_task_wake
681
+ task_wake = @task_wakes.take
682
+ return false unless task_wake
683
+
684
+ run_task_wake_turn(task_wake)
645
685
  true
646
- ensure
647
- @wakes.drop_handed
686
+ end
687
+
688
+ # A turn nobody typed for a finished task: a continue turn from
689
+ # task:<id> whose first message is the wake note, on the delegate wake's
690
+ # rules (#run_wake; counted toward session.max_wakes): a failure that
691
+ # kept nothing, or a Stop that restored the conversation, leaves the
692
+ # note as a plain one (M4).
693
+ def run_task_wake_turn(task_wake)
694
+ Log.info(:worker, "task_wake_turn", task_id: task_wake.id, in_a_row: @wakes.count!)
695
+ run_wake(origin: task_wake.origin, failed: "a finished background task (#{task_wake.name.delete_prefix("task ")})",
696
+ kept: "Its note stays for the user's next message.", mark: task_wake)
648
697
  end
649
698
 
650
699
  # The idle loop's sleep: the fallback tick, or less while reports wait
@@ -901,7 +950,7 @@ module Samagotchi
901
950
  # @param confirm [CacheConfirm::Confirm, nil] the change asks first: the
902
951
  # line that applies it (the TUI's prefill, the web's Apply button)
903
952
  def announce_command(command, status:, output:, changed:, anytime: false, confirm: nil)
904
- TurnCheckpoint.record_command_outcome(@session_dir, command.enqueued_id)
953
+ TurnCheckpoint.record_command_outcome(@session_dir, command.enqueued_id, status: status)
905
954
  text = output.to_s
906
955
  event = { type: :command_ran, **command.event_fields, status: status, output: text[0, COMMAND_OUTPUT_LIMIT],
907
956
  changed: changed.map(&:to_s), model_name: @engine.effective_model_name }
@@ -1200,13 +1249,19 @@ module Samagotchi
1200
1249
  # +delete+: the session is deleted after (/exit --delete), so no recap.
1201
1250
  # +restart+: a new worker takes over (on the newest chi installed), so
1202
1251
  # WorkerIdleExit#hold_for_restart's rules, and no recap or discard.
1203
- # @return [Symbol, nil] what keeps the worker up, nil when it will leave
1252
+ # @return [WorkerIdleExit::Hold, nil] what keeps the worker up (for
1253
+ # :background_tasks, the watched tasks still running), nil when it
1254
+ # will leave
1204
1255
  def exit_request(client_id, delete: false, restart: false)
1205
1256
  # The Bridge serves before the idle-exit policy exists.
1206
- return :starting unless @idle_exit
1257
+ return WorkerIdleExit::Hold.new(reason: :starting) unless @idle_exit
1207
1258
 
1208
1259
  hold = restart ? @idle_exit.hold_for_restart : @idle_exit.hold_for_request(requester: client_id)
1209
- return hold if hold
1260
+ if hold == :background_tasks && !restart
1261
+ # /exit names the tasks it waits for (D3).
1262
+ return WorkerIdleExit::Hold.new(reason: hold, tasks: @task_wakes.watch.running, hint: TASK_STOP_HINT)
1263
+ end
1264
+ return WorkerIdleExit::Hold.new(reason: hold) if hold
1210
1265
 
1211
1266
  @exit_requested = true
1212
1267
  @exit_requested_by = client_id
@@ -1253,10 +1308,17 @@ module Samagotchi
1253
1308
  # A restart keeps even an empty session (a new worker takes it over at
1254
1309
  # once) and leaves the recap to the conversation's next real pause.
1255
1310
  def left(result)
1311
+ deleting = result == :exit_requested && @exit_deletes
1312
+ # The watch goes with this process: a task that finished since the
1313
+ # last look (nothing held the exit for it: queue, a note-only task,
1314
+ # /exit) is a plain note for the next turn, not lost. Not into a
1315
+ # session /exit --delete deletes next: its save could land after the
1316
+ # delete and write the session back.
1317
+ @task_wakes.absorb unless deleting
1256
1318
  return result if result == :restart
1257
1319
 
1258
1320
  @discard = empty_session?
1259
- write_recap_on_leave unless @discard || (result == :exit_requested && @exit_deletes)
1321
+ write_recap_on_leave unless @discard || deleting
1260
1322
  result
1261
1323
  end
1262
1324
 
@@ -13,6 +13,9 @@ module Samagotchi
13
13
  # * a reminder is registered. Reminders live only in the Engine and repeat
14
14
  # until the agent cancels them, so an exit would drop them for good;
15
15
  # * a continue offer waits for an answer; it too lives only in memory;
16
+ # * a background task the worker watches for a finish wake (TaskWatch)
17
+ # still runs, or finished and waits for the next pass's wake: its end
18
+ # would go unnoticed after an exit (D3);
16
19
  # * less than the timeout has passed since the worker started, the Engine's
17
20
  # last activity, or the last client request or disconnect.
18
21
  #
@@ -21,6 +24,15 @@ module Samagotchi
21
24
  # the timeout and leaves out the asking client's own stream. A restart
22
25
  # (POST /exit restart: true) has its own rules, #hold_for_restart.
23
26
  class WorkerIdleExit
27
+ # What keeps the worker up when a client asks it to exit (Worker#exit_request,
28
+ # the Bridge's 409): +reason+, a #hold_for_request (or #hold_for_restart)
29
+ # Symbol, or :starting before this policy exists; for :background_tasks
30
+ # the watched tasks still running ({id:, command:}) and a +hint+ on
31
+ # stopping them.
32
+ Hold = Data.define(:reason, :tasks, :hint) do
33
+ def initialize(reason:, tasks: nil, hint: nil) = super
34
+ end
35
+
24
36
  # @param engine [Engine] #turn_running?, #last_activity_at, #reminder_store
25
37
  # @param bridge [Bridge, nil] #open_streams, #last_client_activity_at; nil
26
38
  # when the worker's Bridge failed to start
@@ -29,15 +41,23 @@ module Samagotchi
29
41
  # @param awaiting_continue [#call] true while a continue offer waits
30
42
  # @param running_tasks [#call] the background tasks this conversation
31
43
  # started that still run (Tools::TaskRuntime.running_created_in)
44
+ # @param watched_tasks [#call] the ones a finish wake would wake for,
45
+ # running or not yet taken (TaskWatch#holding): they hold the idle exit
46
+ # @param watched_running [#call] those of them still running
47
+ # (TaskWatch#running): they hold /exit (one that finished is a note
48
+ # as the worker leaves)
32
49
  # @param clock [#call] monotonic seconds, the Engine's and Bridge's clock
33
50
  def initialize(engine:, bridge:, timeout_minutes:, input_pending:, awaiting_continue: -> { false },
34
- running_tasks: -> { [] }, clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
51
+ running_tasks: -> { [] }, watched_tasks: -> { [] }, watched_running: -> { [] },
52
+ clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
35
53
  @engine = engine
36
54
  @bridge = bridge
37
55
  @timeout = timeout_minutes.to_f * 60
38
56
  @input_pending = input_pending
39
57
  @awaiting_continue = awaiting_continue
40
58
  @running_tasks = running_tasks
59
+ @watched_tasks = watched_tasks
60
+ @watched_running = watched_running
41
61
  @clock = clock
42
62
  @started_at = clock.call
43
63
  end
@@ -49,7 +69,8 @@ module Samagotchi
49
69
 
50
70
  # What keeps the worker up, or nil when nothing does.
51
71
  # @return [Symbol, nil] :disabled, :turn_running, :input_queued,
52
- # :continue_offered, :client_connected, :reminders or :recent_activity
72
+ # :continue_offered, :client_connected, :reminders, :background_tasks
73
+ # or :recent_activity
53
74
  def hold
54
75
  return :disabled unless @timeout.positive?
55
76
  return :turn_running if @engine.turn_running?
@@ -57,6 +78,7 @@ module Samagotchi
57
78
  return :continue_offered if @awaiting_continue.call
58
79
  return :client_connected if @bridge && @bridge.open_streams.positive?
59
80
  return :reminders if @engine.reminder_store&.any?
81
+ return :background_tasks if @watched_tasks.call.any?
60
82
  return :recent_activity if idle_seconds < @timeout
61
83
 
62
84
  nil
@@ -71,13 +93,14 @@ module Samagotchi
71
93
  # either (the last check before leaving, when the asker's stream may
72
94
  # still be open, untagged)
73
95
  # @return [Symbol, nil] :turn_running, :input_queued, :continue_offered,
74
- # :client_connected or :reminders
96
+ # :client_connected, :reminders or :background_tasks
75
97
  def hold_for_request(requester:, streams: true)
76
98
  return :turn_running if @engine.turn_running?
77
99
  return :input_queued if @input_pending.call
78
100
  return :continue_offered if @awaiting_continue.call
79
101
  return :client_connected if streams && @bridge && @bridge.open_streams_except(requester).positive?
80
102
  return :reminders if @engine.reminder_store&.any?
103
+ return :background_tasks if @watched_running.call.any?
81
104
 
82
105
  nil
83
106
  end