samagotchi 0.60.0 → 0.62.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 (82) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +99 -1
  3. data/docs/architecture.md +10 -4
  4. data/docs/cli.md +22 -8
  5. data/docs/configuration.md +32 -19
  6. data/docs/context.md +8 -0
  7. data/docs/guardrails.md +2 -1
  8. data/docs/hooks.md +80 -1
  9. data/docs/internals/engine.md +57 -15
  10. data/docs/internals/prompt-caching.md +8 -1
  11. data/docs/plugins.md +49 -6
  12. data/docs/sessions.md +7 -3
  13. data/docs/testing.md +4 -1
  14. data/lib/samagotchi/answer_display.rb +4 -3
  15. data/lib/samagotchi/answer_refs.rb +210 -0
  16. data/lib/samagotchi/bridge/card_store.rb +6 -1
  17. data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
  18. data/lib/samagotchi/bundles/check-in/plugin.rb +27 -3
  19. data/lib/samagotchi/bundles/github-pr/manifest.yml +3 -3
  20. data/lib/samagotchi/bundles/github-pr/plugin.rb +70 -14
  21. data/lib/samagotchi/bundles/slow-commands/manifest.yml +2 -2
  22. data/lib/samagotchi/bundles/slow-commands/plugin.rb +5 -1
  23. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +58 -3
  24. data/lib/samagotchi/bundles/source-links/manifest.yml +4 -4
  25. data/lib/samagotchi/bundles/source-links/source_links.md +1 -1
  26. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  27. data/lib/samagotchi/engine.rb +47 -9
  28. data/lib/samagotchi/engine_peers.rb +1 -0
  29. data/lib/samagotchi/hooks/registry.rb +3 -1
  30. data/lib/samagotchi/interrupted_turn.rb +39 -0
  31. data/lib/samagotchi/kernel_loop.rb +13 -20
  32. data/lib/samagotchi/llm/chat_loop.rb +28 -17
  33. data/lib/samagotchi/log_subscriber.rb +6 -3
  34. data/lib/samagotchi/plugin/api.rb +36 -18
  35. data/lib/samagotchi/reasoning_history.rb +22 -4
  36. data/lib/samagotchi/ref.rb +25 -8
  37. data/lib/samagotchi/reply_wait.rb +6 -2
  38. data/lib/samagotchi/row_refs.rb +126 -0
  39. data/lib/samagotchi/session.rb +40 -4
  40. data/lib/samagotchi/session_manager.rb +64 -6
  41. data/lib/samagotchi/session_metrics.rb +24 -2
  42. data/lib/samagotchi/terminal_ui/formatting.rb +1 -1
  43. data/lib/samagotchi/terminal_ui.rb +30 -3
  44. data/lib/samagotchi/tool_activity.rb +8 -4
  45. data/lib/samagotchi/tool_response.rb +5 -4
  46. data/lib/samagotchi/tool_row_fields.rb +4 -2
  47. data/lib/samagotchi/tool_runner.rb +16 -3
  48. data/lib/samagotchi/tools/builtins.rb +9 -2
  49. data/lib/samagotchi/tools/execute.rb +15 -3
  50. data/lib/samagotchi/tools/peers.rb +3 -2
  51. data/lib/samagotchi/tools/registry.rb +5 -3
  52. data/lib/samagotchi/turn_checkpoint.rb +128 -0
  53. data/lib/samagotchi/version.rb +1 -1
  54. data/lib/samagotchi/web/app.rb +19 -1
  55. data/lib/samagotchi/web/message_parts.rb +17 -9
  56. data/lib/samagotchi/web/public/activity.js +4 -1
  57. data/lib/samagotchi/web/public/answer_refs.js +217 -0
  58. data/lib/samagotchi/web/public/app.js +74 -33
  59. data/lib/samagotchi/web/public/chains.js +18 -20
  60. data/lib/samagotchi/web/public/context_chips.js +92 -64
  61. data/lib/samagotchi/web/public/families_view.js +51 -39
  62. data/lib/samagotchi/web/public/index.html +66 -13
  63. data/lib/samagotchi/web/public/keep_focus.js +24 -0
  64. data/lib/samagotchi/web/public/lists_view.js +13 -13
  65. data/lib/samagotchi/web/public/llm_context_chip.js +55 -34
  66. data/lib/samagotchi/web/public/question_card.js +11 -0
  67. data/lib/samagotchi/web/public/question_cards.js +60 -5
  68. data/lib/samagotchi/web/public/ref_menu.js +24 -3
  69. data/lib/samagotchi/web/public/ref_scan.js +5 -3
  70. data/lib/samagotchi/web/public/refs.js +140 -20
  71. data/lib/samagotchi/web/public/scroll.js +42 -0
  72. data/lib/samagotchi/web/public/stage_model.js +22 -3
  73. data/lib/samagotchi/web/public/stage_view.js +299 -70
  74. data/lib/samagotchi/web/public/stream_handlers.js +5 -3
  75. data/lib/samagotchi/web/public/timing.js +1 -1
  76. data/lib/samagotchi/web/public/turn_html.js +5 -4
  77. data/lib/samagotchi/web/public/turn_model.js +49 -0
  78. data/lib/samagotchi/web/public/turn_view.js +121 -17
  79. data/lib/samagotchi/worker.rb +169 -21
  80. data/lib/samagotchi/worker_inbound.rb +5 -3
  81. data/lib/samagotchi/worker_signals.rb +81 -0
  82. metadata +8 -1
@@ -7,7 +7,7 @@ import { commandBlockHtml, rowHover } from "./command_view.js";
7
7
  import { copySourceAttr } from "./copy.js";
8
8
  import { activityDiffHtml } from "./diff_view.js";
9
9
  import { escapeHtml, userBodyHtml } from "./format.js";
10
- import { refAttrs } from "./refs.js";
10
+ import { refAttrs, rowTitleRef } from "./refs.js";
11
11
  import { readLinesHtml } from "./read_view.js";
12
12
  import { formatDuration } from "./timing.js";
13
13
  import { emptyAnswerLine } from "./turn_events.js";
@@ -84,9 +84,10 @@ export function toolRowHtml(row, { thumbs = () => "" } = {}) {
84
84
  export function toolRowInnerHtml(row, { thumbs = () => "" } = {}) {
85
85
  const shown = row.title || row.params;
86
86
  const hover = rowHover(row) ? ` title="${escapeHtml(rowHover(row))}"` : "";
87
- // A row with a ref (a file tool's file) carries it on its title, which
88
- // opens it, and its diff lines open it at their line (refs.js).
89
- const ref = shown ? refAttrs(row.ref) : "";
87
+ // A row with a ref (a file tool's file, else a plugin tool's first ref)
88
+ // carries it on its title, which opens it, and its diff lines open it at
89
+ // their line (refs.js; bindRowRefs binds a plugin's data to the title).
90
+ const ref = shown ? refAttrs(rowTitleRef(row)) : "";
90
91
  const params = shown ? `<span class="activity-params${ref ? " ref" : ""}"${hover}${ref}>${escapeHtml(shown)}</span>` : "";
91
92
  // The name the model called the tool by when it was an alias's, dim.
92
93
  const alias = row.called_as ? `<span class="activity-alias">(called as ${escapeHtml(row.called_as)})</span>` : "";
@@ -152,6 +152,20 @@ export function notice(turn, { hook, text, level } = {}) {
152
152
  return { gen };
153
153
  }
154
154
 
155
+ // The whole text of the reply that asked the user (a question or an
156
+ // approval blocks its call): the last gen's text, trimmed, while one of its
157
+ // calls runs and the turn goes on; else "". Only that step's text: a hook's
158
+ // question (asked before its call's row, or between steps) has no running
159
+ // row there, so it gets none rather than another step's (or the answer's)
160
+ // text. The stage shows it while the card waits; the card keeps it once
161
+ // answered.
162
+ export function askingText(turn) {
163
+ if (!turn || turn.ended) return "";
164
+ const gen = turn.gens[turn.gens.length - 1];
165
+ if (!gen?.text || !gen.tools.some((row) => row.status === "running")) return "";
166
+ return gen.text.trim();
167
+ }
168
+
155
169
  // The turn is over (completed, canceled, failed, the worker gone).
156
170
  export function turnEnded(turn) {
157
171
  const closed = closeCurrent(turn);
@@ -215,6 +229,41 @@ export function genLabelParts(gen) {
215
229
  return { head: "thinking", calls: "", repeats: false };
216
230
  }
217
231
 
232
+ // A step that said something (its text is not the turn's answer): a row of
233
+ // its own in a finished block's outline.
234
+ export function isNarrated(gen) {
235
+ return !gen.answer && !!String(gen.text || "").trim();
236
+ }
237
+
238
+ // A finished block's outline (+gens+: its steps in order, as the block holds
239
+ // them): each narrated step a row, each run of the others between (tool-only,
240
+ // thinking-only) one fold ({fold: [indices]}), so every mid-turn text's first
241
+ // line reads without a click and the steps that said nothing take one line.
242
+ // [] when no step said anything: the block stays closed, as before.
243
+ // A last run of one thinking-only step stays a row (no "· 1 step" at the end).
244
+ export function outlineRuns(gens) {
245
+ if (!gens.some(isNarrated)) return [];
246
+ const runs = [];
247
+ gens.forEach((gen, i) => {
248
+ if (isNarrated(gen)) return runs.push({ step: i });
249
+ const last = runs[runs.length - 1];
250
+ if (last?.fold) last.fold.push(i);
251
+ else runs.push({ fold: [i] });
252
+ });
253
+ // A turn doesn't end on a "1 step" fold for a lone thinking-only step
254
+ // (the answer's own thinking): it stays a row, its "thinking" label.
255
+ const last = runs[runs.length - 1];
256
+ const lone = last.fold?.length === 1 && gens[last.fold[0]];
257
+ if (lone && lone.tools.length === 0) runs[runs.length - 1] = { step: last.fold[0] };
258
+ return runs;
259
+ }
260
+
261
+ // A finished turn's steps as its block holds them: the answer's step left it
262
+ // when the answer was all it had (turn_view.js promote).
263
+ export function blockGens(turn) {
264
+ return turn.gens.filter((gen) => !(gen.answer && !gen.thinking && gen.tools.length === 0));
265
+ }
266
+
218
267
  // The block's summary line: "working · 3 steps · 8 tool calls · execute ×7 · …"
219
268
  // while it runs, without the prefix after; the tally (tally.js) from 3 calls.
220
269
  export function blockSummary(turn, { running = false } = {}) {
@@ -21,6 +21,8 @@
21
21
  // chrome (CSS :has), so a plain answer streams like today's bubble, and is
22
22
  // removed at the end: the answer bubble takes its place.
23
23
  import { html, nothing, render } from "./vendor/lit-html.js";
24
+ import { setAnswerRefs } from "./answer_refs.js";
25
+ import { bindRowRefs } from "./refs.js";
24
26
  import { leavesBlock } from "./card.js";
25
27
  import { copySourceAttr } from "./copy.js";
26
28
  import { escapeHtml, failedTurnText, messageBodyHtml, noteHtml, normalize, sourceLabel, steerRowHtml } from "./format.js";
@@ -115,6 +117,67 @@ function rawNodes(markup) {
115
117
  return t.content;
116
118
  }
117
119
 
120
+ // ---- A finished block's outline ----
121
+
122
+ // A finished block as its outline (turn_model.js outlineRuns of +gens+, its
123
+ // steps in order): open, each narrated step a one-line row as before, each
124
+ // run of steps that said nothing hidden behind one "N steps" fold that
125
+ // shows them. The live turn's end and a reload call it alike. The folds sit
126
+ // between the steps, not in them (`:scope > .gen` still counts steps), each
127
+ // before its run's first step and before that step's lit marker comment, so
128
+ // a later render of the step leaves it alone. False when there is nothing
129
+ // to outline (no step said anything): the block stays as it is.
130
+ export function outlineBlock(blockEl, gens) {
131
+ const runs = model.outlineRuns(gens);
132
+ const steps = blockEl ? [...blockEl.querySelectorAll(":scope > .gen")] : [];
133
+ if (!runs.length || steps.length !== gens.length) return false;
134
+ blockEl.classList.add("outline");
135
+ // A folded step → its fold's opener: a ref's "show in turn" (refs.js
136
+ // showInTurn dispatches ref:reveal on the row before it scrolls) unfolds
137
+ // the run its row is in, or the row stays hidden.
138
+ const unfold = new Map();
139
+ for (const { fold } of runs) {
140
+ if (!fold) continue;
141
+ const folded = fold.map((i) => steps[i]);
142
+ folded.forEach((el) => el.classList.add("folded"));
143
+ const marker = document.createElement("div");
144
+ marker.className = "outline-fold";
145
+ marker.setAttribute("role", "button");
146
+ marker.tabIndex = 0;
147
+ marker.setAttribute("aria-expanded", "false");
148
+ marker.textContent = `${fold.length} step${fold.length === 1 ? "" : "s"}`;
149
+ const toggle = () => {
150
+ const open = marker.classList.toggle("open");
151
+ marker.setAttribute("aria-expanded", String(open));
152
+ folded.forEach((el) => el.classList.toggle("unfolded", open));
153
+ };
154
+ folded.forEach((el) => unfold.set(el, () => { if (!marker.classList.contains("open")) toggle(); }));
155
+ marker.addEventListener("click", toggle);
156
+ marker.addEventListener("keydown", (e) => {
157
+ if (e.key !== "Enter" && e.key !== " ") return;
158
+ e.preventDefault();
159
+ toggle();
160
+ });
161
+ const first = folded[0];
162
+ const anchor = first.previousSibling?.nodeType === 8 ? first.previousSibling : first;
163
+ anchor.before(marker);
164
+ }
165
+ blockEl.addEventListener("ref:reveal", (e) => {
166
+ const step = e.target.closest?.(".gen.folded");
167
+ if (step?.parentElement === blockEl) unfold.get(step)?.();
168
+ });
169
+ blockEl.open = true;
170
+ return true;
171
+ }
172
+
173
+ // Reloaded tool rows (turn_html.js toolRowHtml) as nodes, each row's
174
+ // plugin refs bound to its title (refs.js bindRowRefs).
175
+ function rowNodes(rows, thumbs) {
176
+ const nodes = rawNodes(rows.map((r) => toolRowHtml(r, { thumbs })).join(""));
177
+ [...nodes.children].forEach((el, i) => bindRowRefs(el, rows[i]));
178
+ return nodes;
179
+ }
180
+
118
181
  // ---- The live view ----
119
182
 
120
183
  // The parts of a drawn tool row a user may have changed (the command's
@@ -444,12 +507,16 @@ export function createTurnView({
444
507
  const inner = toolRowInnerHtml(r.row, { thumbs: rowThumbs });
445
508
  if (!el) {
446
509
  el = htmlElement(toolRowHtml(r.row, { thumbs: rowThumbs }));
510
+ bindRowRefs(el, r.row);
447
511
  rowHtml.set(el, inner);
448
512
  entry.rows.set(r.row.key, el);
449
513
  addItem(entry, el);
450
514
  } else {
451
515
  if (!r.gen.text) entry.placeholder = false;
452
- if (rowHtml.get(el) !== inner) redrawRow(el, inner);
516
+ if (rowHtml.get(el) !== inner) {
517
+ redrawRow(el, inner);
518
+ bindRowRefs(el, r.row);
519
+ }
453
520
  rowHtml.set(el, inner);
454
521
  // A late result in a closed step: its label and failed mark.
455
522
  draw(entry);
@@ -602,7 +669,11 @@ export function createTurnView({
602
669
  if (blockEl) {
603
670
  updateBlockSummary();
604
671
  blockEl.classList.add("done");
605
- if (!blockToggled) blockEl.open = false;
672
+ // The outline (turn_model.js outlineRuns): open on its narrated steps'
673
+ // rows, as a reload draws it; a block with nothing said closes.
674
+ if (newestFirst) chronological();
675
+ const outlined = outlineBlock(blockEl, model.blockGens(turn));
676
+ if (!blockToggled) blockEl.open = outlined;
606
677
  for (const el of blockEl.querySelectorAll(".plugin-card")) {
607
678
  if (leavesBlock({ warn: el.classList.contains("warn"), kind })) kept.push(el);
608
679
  }
@@ -614,6 +685,20 @@ export function createTurnView({
614
685
  return kept;
615
686
  }
616
687
 
688
+ // The steps back in chronological order after newestFirst (a no-op
689
+ // otherwise); a fold (outlineBlock) stays before its run's first step.
690
+ function chronological() {
691
+ if (!blockEl || !turn) return;
692
+ for (const gen of turn.gens) {
693
+ const entry = genEls.get(gen);
694
+ if (entry?.el.parentNode !== blockEl) continue;
695
+ const nodes = stepNodes(entry);
696
+ const fold = nodes[0].previousSibling;
697
+ if (fold?.classList?.contains("outline-fold")) nodes.unshift(fold);
698
+ blockEl.append(...nodes);
699
+ }
700
+ }
701
+
617
702
  function reset() {
618
703
  hold.finish();
619
704
  turn = null;
@@ -711,17 +796,14 @@ export function createTurnView({
711
796
  // block, a step's elements by index, and the steps back in
712
797
  // chronological order after newestFirst.
713
798
  current() { return { turn, blockEl }; },
799
+ // The running turn's asking reply (turn_model.js askingText): what a
800
+ // question or approval card keeps once answered.
801
+ askingText: () => model.askingText(turn),
714
802
  step(i) {
715
803
  const entry = turn && genEls.get(turn.gens[i]);
716
804
  return entry ? { el: entry.el, summary: entry.el.querySelector(":scope > summary"), thinkingWrap: thinkingWrap(entry) } : null;
717
805
  },
718
- chronological() {
719
- if (!blockEl || !turn) return;
720
- for (const gen of turn.gens) {
721
- const entry = genEls.get(gen);
722
- if (entry?.el.parentNode === blockEl) blockEl.append(...stepNodes(entry));
723
- }
724
- },
806
+ chronological,
725
807
  };
726
808
  }
727
809
 
@@ -736,6 +818,14 @@ function defaultPlaceAnswer(appendToHistory) {
736
818
 
737
819
  // ---- A reload ----
738
820
 
821
+ // The text of a reloaded block's step +iteration+ (1-based, as the snapshot's
822
+ // cards count), "" when it has none: a reloaded question card's asking
823
+ // reply.
824
+ export function stepText(block, iteration) {
825
+ const step = block?.querySelectorAll(":scope > .gen")[Number(iteration) - 1];
826
+ return step?.querySelector(":scope > .gen-text")?.textContent || "";
827
+ }
828
+
739
829
  // A re-rendered failed turn's line (a .bubble.cancel, as live's
740
830
  // addEndBubble), in turn_failed's words (failedTurnText) from what the turn
741
831
  // record kept of it (SessionMetrics' failure: summary, message,
@@ -779,10 +869,12 @@ export function turnHistoryNodes(items, timing, { thumbs }) {
779
869
  : "";
780
870
  };
781
871
  // The timing line after the bubble, not in it: where the live view
782
- // leaves it at the turn's end.
783
- const answerHtml = (m, timing) => {
872
+ // leaves it at the turn's end. +at+: the message's index, for its refs
873
+ // (data-answer-at, gone before the nodes are returned).
874
+ const answerHtml = (at, timing) => {
875
+ const m = items[at];
784
876
  const { body, renderedMarkdown } = messageBodyHtml(m);
785
- return `<div class="bubble output${renderedMarkdown ? " markdown" : ""}"${copySourceAttr(m, renderedMarkdown)}>${body}</div>${timing}`;
877
+ return `<div class="bubble output${renderedMarkdown ? " markdown" : ""}" data-answer-at="${at}"${copySourceAttr(m, renderedMarkdown)}>${body}</div>${timing}`;
786
878
  };
787
879
  // A user's line, labelled when chi brought it in (a delegate report). A
788
880
  // `!cmd`'s saved message (the worker's "!(<command>)\n<output>") renders
@@ -799,14 +891,16 @@ export function turnHistoryNodes(items, timing, { thumbs }) {
799
891
  const j = items.findIndex((m, k) => k > i && m?.role === "assistant" && String(m.content ?? "").trim());
800
892
  if (j < 0 || j === group.answer || items.slice(i + 1, j).some((m) => m?.role === "user" && !m.merged)) return "";
801
893
  const { body, renderedMarkdown } = messageBodyHtml(items[j]);
802
- return `<div class="bubble output report-reply${renderedMarkdown ? " markdown" : ""}"${copySourceAttr(items[j], renderedMarkdown)}>${body}</div>`;
894
+ return `<div class="bubble output report-reply${renderedMarkdown ? " markdown" : ""}" data-answer-at="${j}"${copySourceAttr(items[j], renderedMarkdown)}>${body}</div>`;
803
895
  };
804
896
  const steerHtml = (i, bubble) => steerRowHtml({ source: items[i].source, text: items[i].content }, { bubble });
805
897
  const parts = [];
898
+ // Each block's steps, in order: its outline once the nodes exist.
899
+ const outlines = [];
806
900
  for (const group of turnGroups(items, timing)) {
807
901
  if (group.kind === "note") { parts.push(`<div class="bubble note">${noteHtml(items[group.i])}</div>`); continue; }
808
902
  if (group.kind === "steer") { parts.push(steerHtml(group.i, true)); continue; }
809
- if (group.kind === "answer") { parts.push(answerHtml(items[group.answer], "")); continue; }
903
+ if (group.kind === "answer") { parts.push(answerHtml(group.answer, "")); continue; }
810
904
  const prompt = items[group.user];
811
905
  // A context wake turn starts with its note.
812
906
  parts.push(prompt.role === "note" ? `<div class="bubble note">${noteHtml(prompt)}</div>` : userHtml(prompt));
@@ -824,9 +918,10 @@ export function turnHistoryNodes(items, timing, { thumbs }) {
824
918
  const steps = gens.map((gen, k) => stepTemplate(gen, {
825
919
  // The steers the step answered first, where the live rows went.
826
920
  steers: (group.steps[k].steers || []).map((i) => rawNodes(steerHtml(i, false))),
827
- items: gen.tools.length ? [rawNodes(gen.tools.map((r) => toolRowHtml(r, { thumbs })).join(""))] : [],
921
+ items: gen.tools.length ? [rowNodes(gen.tools, thumbs)] : [],
828
922
  }));
829
923
  parts.push(html`<details class="turn-work done"><summary>${model.blockSummary({ gens, ended: true })}</summary>${steps}</details>`);
924
+ outlines.push(gens);
830
925
  }
831
926
  // Steers with no step to sit in, or in a turn that shows no block: on
832
927
  // their own after the prompt.
@@ -846,11 +941,20 @@ export function turnHistoryNodes(items, timing, { thumbs }) {
846
941
  // A turn with no answer (canceled, or running: no record yet) shows its
847
942
  // timing after its prompt and steps, where the live view leaves it.
848
943
  else if (group.answer === null) parts.push(timingHtml(group));
849
- else parts.push(answerHtml(items[group.answer], timingHtml(group)));
944
+ else parts.push(answerHtml(group.answer, timingHtml(group)));
850
945
  // A canceled turn ends with its cancel line, a failed one whose steps
851
946
  // stayed with its failure line, as the live view left them.
852
947
  parts.push(cancelLineHtml(group.record, escapeHtml));
853
948
  parts.push(failedLineHtml(group.record));
854
949
  }
855
- return fragmentOf(parts);
950
+ const out = fragmentOf(parts);
951
+ // A finished turn's block as the live end leaves it: its outline, open.
952
+ [...out.children].filter((el) => el.matches(".turn-work")).forEach((block, i) => outlineBlock(block, outlines[i] || []));
953
+ // Each answer bubble knows its message's refs (answer_refs.js; the
954
+ // page pairs them after the render), the index stays out of the DOM.
955
+ for (const bubble of out.querySelectorAll("[data-answer-at]")) {
956
+ setAnswerRefs(bubble, items[Number(bubble.dataset.answerAt)]?.display_refs);
957
+ bubble.removeAttribute("data-answer-at");
958
+ }
959
+ return out;
856
960
  }
@@ -12,10 +12,12 @@ require_relative "steer"
12
12
  require_relative "worker_idle_exit"
13
13
  require_relative "worker_wakes"
14
14
  require_relative "worker_inbound"
15
+ require_relative "worker_signals"
15
16
  require_relative "session_manager"
16
17
  require_relative "archive_store"
17
18
  require_relative "log"
18
19
  require_relative "turn_flow"
20
+ require_relative "turn_checkpoint"
19
21
  require_relative "llm/errors"
20
22
  require_relative "continue_offer"
21
23
  require_relative "iteration_limit"
@@ -52,6 +54,13 @@ module Samagotchi
52
54
  # /continue) run on the loop between turns, before any queued prompt. One
53
55
  # taken while a turn runs (at an iteration boundary, or right after the
54
56
  # turn) is refused as busy.
57
+ #
58
+ # A TERM, HUP or INT (WorkerSignals, trapped in a spawned worker only)
59
+ # stops the running turn as a Stop does (reason :signal), the turn's
60
+ # work is saved as after any Stop, and the worker leaves (:signal) taking
61
+ # no new work. A turn that hasn't ended SIGNAL_GRACE seconds after the
62
+ # signal is cut: the session is saved as it is and the signal raised in
63
+ # the loop's thread, as Ruby's default would (the teardown still runs).
55
64
  class Worker
56
65
  FALLBACK_TICK_SECONDS = 5
57
66
  # A worker just started runs no wake turn for delegate reports this
@@ -64,6 +73,18 @@ module Samagotchi
64
73
  BUSY_OUTPUT = "busy: wait for the turn to end"
65
74
  # !rollback mid-turn would mean "the turn I'm in".
66
75
  ROLLBACK_BUSY_OUTPUT = "busy: Ctrl-C the turn first, then !rollback"
76
+ # How long a signalled worker's turn has to end (it is canceled at once,
77
+ # and again every SIGNAL_RETRY until it ends: a signal that lands just
78
+ # before the turn begins finds none to cancel).
79
+ SIGNAL_GRACE = 3.0
80
+ # A second signal, or a TERM after `chi sessions stop` marked the
81
+ # session stopped (that stop already canceled over the Bridge and waits
82
+ # only seconds for the lock): this much.
83
+ SIGNAL_SHORT_GRACE = 0.5
84
+ SIGNAL_RETRY = 0.1
85
+ # After the grace ran out and the signal was raised: the process ends
86
+ # this much later at the latest (a teardown that hangs).
87
+ SIGNAL_HARD_EXIT = 10.0
67
88
 
68
89
  # Wakes the worker loop. Whoever queues work writes it first and wakes
69
90
  # after, and #wait drains every wake before the loop looks for work: a
@@ -90,7 +111,12 @@ module Samagotchi
90
111
 
91
112
  # @param idle_exit_minutes [Numeric, nil] nil: session.idle_exit_minutes
92
113
  # @param poll_interval [Numeric, nil] seconds between fallback ticks
93
- def initialize(session_id:, state_dir:, session_dir:, idle_exit_minutes: nil, poll_interval: nil)
114
+ # @param signals [WorkerSignals, WorkerSignals::None] the process's
115
+ # trapped signals (SessionManager.run_session_loop); none by default
116
+ # @param signal_grace [Array(Numeric, Numeric, Numeric)] the grace, the
117
+ # short grace and the hard exit's delay (specs shorten them)
118
+ def initialize(session_id:, state_dir:, session_dir:, idle_exit_minutes: nil, poll_interval: nil,
119
+ signals: WorkerSignals::NONE, signal_grace: [SIGNAL_GRACE, SIGNAL_SHORT_GRACE, SIGNAL_HARD_EXIT])
94
120
  @session_id = session_id
95
121
  @state_dir = state_dir
96
122
  @session_dir = session_dir
@@ -116,6 +142,20 @@ module Samagotchi
116
142
  @asked_parent = false
117
143
  @owes_parent = false
118
144
  @initial_turn = false
145
+ @signals = signals
146
+ @signal_grace, @signal_short_grace, @signal_hard_exit = signal_grace
147
+ # Set as the loop starts, and as it returns (or raises): a signal's
148
+ # grace runs out only in between.
149
+ @loop_started = false
150
+ @left_loop = false
151
+ @signal_mutex = Mutex.new
152
+ @grace_expired = false
153
+ # The running turn has ended (its end event) and its after_turn hooks
154
+ # run: a signal's grace waits for them (#signalled).
155
+ @turn_ending = false
156
+ # The worker's own saves and a signal's (#signal_grace_expired), one
157
+ # at a time.
158
+ @save_mutex = Mutex.new
119
159
  end
120
160
 
121
161
  # Whether the session was empty as the worker left it, so the caller
@@ -135,30 +175,46 @@ module Samagotchi
135
175
  # @return [Symbol] :idle_exit, :exit_requested when a client asked it
136
176
  # to exit (Bridge POST /exit), :restart when it asked for a restart
137
177
  # (the caller starts a new worker), :stopped when the session was stopped
138
- # (chi stop), :crashed when the loop raised (the session is marked
139
- # errored); SessionManager.run_session_loop turns it into the exit
178
+ # (chi stop), :signal when a signal stopped it (WorkerSignals),
179
+ # :crashed when the loop raised (the session is marked errored);
180
+ # SessionManager.run_session_loop turns it into the exit
140
181
  def run
182
+ # The thread a signal's expired grace raises in (the main thread in a
183
+ # worker process).
184
+ @run_thread = Thread.current
185
+ @signals.on_signal { |name, count| signalled(name, count) }
141
186
  start
142
187
  serve
143
- begin
144
- # A session stopped before this worker took the lock (e.g. a stop
145
- # right after create) must not run its initial prompt.
146
- return :stopped if stopped_on_disk?
147
-
148
- start_background
149
- loop do
150
- leaving = pass
151
- return leaving if leaving
152
- end
188
+ # That raise lands in the loop only (#signal_grace_expired arms once
189
+ # it started, and never after it left): the teardown always runs
190
+ # whole, a raise already on its way waits until it is done.
191
+ Thread.handle_interrupt(SignalException => :never) do
192
+ Thread.handle_interrupt(SignalException => :immediate) { run_loop }
153
193
  rescue StandardError => e
154
194
  crashed(e)
155
195
  ensure
196
+ @signal_mutex.synchronize { @left_loop = true }
156
197
  teardown
157
198
  end
158
199
  end
159
200
 
160
201
  private
161
202
 
203
+ # @return [Symbol] what #run returns
204
+ def run_loop
205
+ @signal_mutex.synchronize { @loop_started = true }
206
+ # A session stopped before this worker took the lock (e.g. a stop
207
+ # right after create) must not run its initial prompt.
208
+ return :stopped if stopped_on_disk?
209
+ return signal_leave if signalled?
210
+
211
+ start_background
212
+ loop do
213
+ leaving = pass
214
+ return leaving if leaving
215
+ end
216
+ end
217
+
162
218
  # The session, its Engine and what runs its turns, before anyone can
163
219
  # reach it.
164
220
  def start
@@ -171,7 +227,10 @@ module Samagotchi
171
227
  ChildMove.adopt(@session_id, state_dir: @state_dir)
172
228
  @session = Session.load(@session_id, state_dir: @state_dir)
173
229
  drop_dead_question
230
+ # The running turn, on disk at its iteration boundaries.
231
+ @checkpoint = TurnCheckpoint.new(session: @session, state_dir: @state_dir)
174
232
  @engine = build_engine
233
+ settle_interrupted_turn
175
234
  # Before the Bridge serves anything: a UI joining a resumed worker's
176
235
  # stream gets the session's history and status in its snapshot, not
177
236
  # an empty session until the first turn.
@@ -200,8 +259,12 @@ module Samagotchi
200
259
  if Events::TURN_END.include?(event[:type])
201
260
  @turn_end_seq = event[:event_seq]
202
261
  @turn_end_type = event[:type]
262
+ @turn_ending = true
263
+ end
264
+ if event[:type] == :turn_started
265
+ @turn_id = event[:turn_id]
266
+ @turn_ending = false
203
267
  end
204
- @turn_id = event[:turn_id] if event[:type] == :turn_started
205
268
  ring_question(event) if event[:type] == :question_requested
206
269
  })
207
270
  # This session's own delegate children's news (rings in children/).
@@ -268,6 +331,9 @@ module Samagotchi
268
331
  # next pass looks again; with none, #idle_pass.
269
332
  # @return [Symbol, nil] what #run returns as the worker leaves, nil to go on
270
333
  def pass
334
+ # A signalled worker takes no new work.
335
+ return signal_leave if signalled?
336
+
271
337
  # Check if the session was externally marked as stopped. Not
272
338
  # stopped_on_disk?, which reads a vanished file as "not stopped":
273
339
  # a session file deleted under a running worker crashes it here
@@ -316,8 +382,8 @@ module Samagotchi
316
382
  # @return [nil]
317
383
  def run_input_files(input_files)
318
384
  input_files.sort.each do |input_file|
319
- # A stop between two queued turns leaves the rest queued.
320
- break if stopped_on_disk?
385
+ # A stop (or a signal) between two queued turns leaves the rest queued.
386
+ break if signalled? || stopped_on_disk?
321
387
 
322
388
  # Commands that came before this file (D1: a queued /model X sent
323
389
  # before this prompt, a command an earlier file carried) run first.
@@ -385,6 +451,17 @@ module Samagotchi
385
451
  engine
386
452
  end
387
453
 
454
+ # A turn an earlier worker died in (TurnCheckpoint's mark in the file):
455
+ # ended now as canceled, with its note, and saved, before the Bridge
456
+ # serves the session (TurnCheckpoint.settle).
457
+ def settle_interrupted_turn
458
+ mark = TurnCheckpoint.settle(@session)
459
+ return unless mark
460
+
461
+ Log.info(:worker, "interrupted_turn_settled", turn_id: mark.id, iteration: mark.iteration, at: mark.at)
462
+ save_or_log(:interrupted_turn) { @session.save(state_dir: @state_dir) }
463
+ end
464
+
388
465
  # A question saved by a worker that died while it waited (an
389
466
  # ask_user_question, an approval, a hook's question): the turn that
390
467
  # asked is gone, so nobody can answer it, and left in the file it
@@ -572,7 +649,8 @@ module Samagotchi
572
649
  # @return [Boolean] whether any command ran
573
650
  def run_queued_commands
574
651
  ran = false
575
- while (command = next_ready_command)
652
+ # A signalled worker runs no more (a /continue would start a turn).
653
+ while !signalled? && (command = next_ready_command)
576
654
  run_command(command)
577
655
  ran = true
578
656
  end
@@ -822,6 +900,7 @@ module Samagotchi
822
900
  @reporting_turn = reports_to_parent?(turn_args[:origin])
823
901
  restores = @turn_flow.restores
824
902
  begin
903
+ @checkpoint.begin_turn(prompt: prompt)
825
904
  @session.status = Session::STATUS_RUNNING
826
905
  # A continue that moved this session to the chain's new link wrote
827
906
  # the new parent beside it (Session::PARENT_FILE): this turn's
@@ -840,6 +919,8 @@ module Samagotchi
840
919
  Log.warn(:worker, "turn_not_begun", error: e.class.name, msg: e.message)
841
920
  end
842
921
  ensure
922
+ # Ended (or never begun): the saves write the session's own messages.
923
+ @checkpoint.end_turn
843
924
  refuse_queued_commands
844
925
  end
845
926
  yield result, error
@@ -995,6 +1076,10 @@ module Samagotchi
995
1076
  reports.map(&:line)
996
1077
  # A wake turn starts with its first line, whoever sent it.
997
1078
  lines[0] = lines[0].with(mark: mark) if mark && lines.any?
1079
+ # The turn so far, with what this boundary merges (its input files
1080
+ # are gone now), on disk at most every TurnCheckpoint::INTERVAL;
1081
+ # its metrics so far with it, so a turn a KILL ends keeps them.
1082
+ @engine.checkpoint_metrics if @checkpoint.boundary(@engine.live_conversation, merging: lines, turn_id: @turn_id)
998
1083
  lines
999
1084
  end
1000
1085
  end
@@ -1127,11 +1212,74 @@ module Samagotchi
1127
1212
  SessionManager.stopped_on_disk?(@session_id, state_dir: @state_dir)
1128
1213
  end
1129
1214
 
1130
- # Save the session unless it was stopped meanwhile: the stop (chi stop,
1131
- # from another process) saved its status, and this save would write
1132
- # the worker's over it.
1215
+ # Save the session, a stopped one too: a stop is the marker file
1216
+ # (Session.mark_stopped), which every load reads over the saved status,
1217
+ # so this save can't hide it, and the turn the stop canceled is kept.
1133
1218
  def save_session
1134
- @session.save(state_dir: @state_dir) unless stopped_on_disk?
1219
+ @save_mutex.synchronize { @session.save(state_dir: @state_dir) }
1220
+ end
1221
+
1222
+ def signalled? = !@signals.signal.nil?
1223
+
1224
+ # @return [Symbol] :stopped when the session was stopped too (chi
1225
+ # sessions stop TERMs its worker), else :signal
1226
+ def signal_leave
1227
+ Session.stopped_marker?(@session_id, state_dir: @state_dir) ? :stopped : :signal
1228
+ end
1229
+
1230
+ # A signal (WorkerSignals#fire), on a thread of its own: the loop wakes
1231
+ # (an idle worker leaves at once), the running turn is canceled as a
1232
+ # Stop (again every SIGNAL_RETRY, until the loop has left), and when
1233
+ # the grace runs out first, #signal_grace_expired. A turn that has
1234
+ # ended and runs its after_turn hooks (which store the answer's display
1235
+ # and refs) is no longer canceled, and gets until SIGNAL_HARD_EXIT
1236
+ # past the grace, the worker's own end of the turn (its saves, after
1237
+ # the turn stopped running) too: the loop leaves next. A grace that runs out before the loop started waits
1238
+ # for it (the loop's first look leaves). A signal after the loop left
1239
+ # (the teardown, the caller's last steps) only brings the hard exit.
1240
+ def signalled(name, count)
1241
+ Log.info(:worker, "signal", signal: name, count: count, turn_running: @engine&.turn_running? || false)
1242
+ return signal_hard_exit if @left_loop
1243
+
1244
+ @waker.wake
1245
+ short = count > 1 || Session.stopped_marker?(@session_id, state_dir: @state_dir)
1246
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
1247
+ deadline = now + (short ? @signal_short_grace : @signal_grace)
1248
+ hooks_deadline = deadline + @signal_hard_exit
1249
+ ended = false
1250
+ until @left_loop
1251
+ running = @engine&.turn_running?
1252
+ ended ||= running && @turn_ending
1253
+ @engine.cancel_current_turn!(:signal) if running && !ended
1254
+ break if @loop_started && Process.clock_gettime(Process::CLOCK_MONOTONIC) >= (ended ? hooks_deadline : deadline)
1255
+
1256
+ sleep(SIGNAL_RETRY)
1257
+ end
1258
+ signal_grace_expired(name) unless @left_loop
1259
+ rescue StandardError => e
1260
+ Log.warn(:worker, "signal_failed", signal: name, error: e.class.name, msg: e.message)
1261
+ end
1262
+
1263
+ # The process ends SIGNAL_HARD_EXIT from now at the latest.
1264
+ def signal_hard_exit
1265
+ sleep(@signal_hard_exit)
1266
+ @signals.hard_exit
1267
+ end
1268
+
1269
+ # The turn didn't end in time (a tool that ignores the cancel, slow
1270
+ # after_turn hooks): the session is saved as it is now (a turn that has
1271
+ # ended: with it), and the signal raised in the loop's thread, so
1272
+ # Worker#run's teardown and the lock's release still run. The process
1273
+ # ends SIGNAL_HARD_EXIT later at the latest. Once, whatever the signals.
1274
+ def signal_grace_expired(name)
1275
+ expired = @signal_mutex.synchronize { !@grace_expired && !@left_loop && (@grace_expired = true) }
1276
+ return unless expired
1277
+
1278
+ Log.warn(:worker, "signal_grace_expired", signal: name, turn_running: @engine&.turn_running? || false)
1279
+ save_or_log(:signal) { save_session }
1280
+ # Not once the loop has left (it may have, during the save).
1281
+ @signal_mutex.synchronize { @run_thread&.raise(SignalException.new(name)) unless @left_loop }
1282
+ signal_hard_exit
1135
1283
  end
1136
1284
  end
1137
1285
  end
@@ -201,9 +201,11 @@ module Samagotchi
201
201
 
202
202
  private
203
203
 
204
- # +unless_stopped+: not when the session was stopped meanwhile: the stop
205
- # (chi stop, from another process) saved its status, and this save
206
- # would write the worker's over it.
204
+ # +unless_stopped+: skipped when the session was stopped meanwhile (chi
205
+ # sessions stop, from another process: its marker file,
206
+ # Session.mark_stopped, which a load reads over the saved status). The
207
+ # save couldn't hide the stop; it is left out so the file stays as the
208
+ # stop found it, the worker's idle status unwritten.
207
209
  def save(at, unless_stopped: false)
208
210
  self.class.save_or_log(at) do
209
211
  @session.save(state_dir: @state_dir) unless unless_stopped && @stopped.call