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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +99 -1
- data/docs/architecture.md +10 -4
- data/docs/cli.md +22 -8
- data/docs/configuration.md +32 -19
- data/docs/context.md +8 -0
- data/docs/guardrails.md +2 -1
- data/docs/hooks.md +80 -1
- data/docs/internals/engine.md +57 -15
- data/docs/internals/prompt-caching.md +8 -1
- data/docs/plugins.md +49 -6
- data/docs/sessions.md +7 -3
- data/docs/testing.md +4 -1
- data/lib/samagotchi/answer_display.rb +4 -3
- data/lib/samagotchi/answer_refs.rb +210 -0
- data/lib/samagotchi/bridge/card_store.rb +6 -1
- data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
- data/lib/samagotchi/bundles/check-in/plugin.rb +27 -3
- data/lib/samagotchi/bundles/github-pr/manifest.yml +3 -3
- data/lib/samagotchi/bundles/github-pr/plugin.rb +70 -14
- data/lib/samagotchi/bundles/slow-commands/manifest.yml +2 -2
- data/lib/samagotchi/bundles/slow-commands/plugin.rb +5 -1
- data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +58 -3
- data/lib/samagotchi/bundles/source-links/manifest.yml +4 -4
- data/lib/samagotchi/bundles/source-links/source_links.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/engine.rb +47 -9
- data/lib/samagotchi/engine_peers.rb +1 -0
- data/lib/samagotchi/hooks/registry.rb +3 -1
- data/lib/samagotchi/interrupted_turn.rb +39 -0
- data/lib/samagotchi/kernel_loop.rb +13 -20
- data/lib/samagotchi/llm/chat_loop.rb +28 -17
- data/lib/samagotchi/log_subscriber.rb +6 -3
- data/lib/samagotchi/plugin/api.rb +36 -18
- data/lib/samagotchi/reasoning_history.rb +22 -4
- data/lib/samagotchi/ref.rb +25 -8
- data/lib/samagotchi/reply_wait.rb +6 -2
- data/lib/samagotchi/row_refs.rb +126 -0
- data/lib/samagotchi/session.rb +40 -4
- data/lib/samagotchi/session_manager.rb +64 -6
- data/lib/samagotchi/session_metrics.rb +24 -2
- data/lib/samagotchi/terminal_ui/formatting.rb +1 -1
- data/lib/samagotchi/terminal_ui.rb +30 -3
- data/lib/samagotchi/tool_activity.rb +8 -4
- data/lib/samagotchi/tool_response.rb +5 -4
- data/lib/samagotchi/tool_row_fields.rb +4 -2
- data/lib/samagotchi/tool_runner.rb +16 -3
- data/lib/samagotchi/tools/builtins.rb +9 -2
- data/lib/samagotchi/tools/execute.rb +15 -3
- data/lib/samagotchi/tools/peers.rb +3 -2
- data/lib/samagotchi/tools/registry.rb +5 -3
- data/lib/samagotchi/turn_checkpoint.rb +128 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +19 -1
- data/lib/samagotchi/web/message_parts.rb +17 -9
- data/lib/samagotchi/web/public/activity.js +4 -1
- data/lib/samagotchi/web/public/answer_refs.js +217 -0
- data/lib/samagotchi/web/public/app.js +74 -33
- data/lib/samagotchi/web/public/chains.js +18 -20
- data/lib/samagotchi/web/public/context_chips.js +92 -64
- data/lib/samagotchi/web/public/families_view.js +51 -39
- data/lib/samagotchi/web/public/index.html +66 -13
- data/lib/samagotchi/web/public/keep_focus.js +24 -0
- data/lib/samagotchi/web/public/lists_view.js +13 -13
- data/lib/samagotchi/web/public/llm_context_chip.js +55 -34
- data/lib/samagotchi/web/public/question_card.js +11 -0
- data/lib/samagotchi/web/public/question_cards.js +60 -5
- data/lib/samagotchi/web/public/ref_menu.js +24 -3
- data/lib/samagotchi/web/public/ref_scan.js +5 -3
- data/lib/samagotchi/web/public/refs.js +140 -20
- data/lib/samagotchi/web/public/scroll.js +42 -0
- data/lib/samagotchi/web/public/stage_model.js +22 -3
- data/lib/samagotchi/web/public/stage_view.js +299 -70
- data/lib/samagotchi/web/public/stream_handlers.js +5 -3
- data/lib/samagotchi/web/public/timing.js +1 -1
- data/lib/samagotchi/web/public/turn_html.js +5 -4
- data/lib/samagotchi/web/public/turn_model.js +49 -0
- data/lib/samagotchi/web/public/turn_view.js +121 -17
- data/lib/samagotchi/worker.rb +169 -21
- data/lib/samagotchi/worker_inbound.rb +5 -3
- data/lib/samagotchi/worker_signals.rb +81 -0
- 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
|
|
88
|
-
// opens it, and its diff lines open it at
|
|
89
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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 ? [
|
|
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(
|
|
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
|
-
|
|
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
|
}
|
data/lib/samagotchi/worker.rb
CHANGED
|
@@ -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
|
-
|
|
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), :
|
|
139
|
-
#
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
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
|
|
1131
|
-
#
|
|
1132
|
-
#
|
|
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)
|
|
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+:
|
|
205
|
-
#
|
|
206
|
-
#
|
|
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
|