samagotchi 0.115.0 → 0.117.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 +76 -1
- data/bin/chi +2 -0
- data/docs/cli.md +33 -2
- data/docs/configuration.md +13 -0
- data/docs/guardrails.md +188 -1
- data/docs/internals/background-tasks.md +1 -1
- data/docs/sessions.md +3 -3
- data/lib/samagotchi/bridge/turn_accumulator.rb +4 -0
- data/lib/samagotchi/bridge.rb +114 -14
- data/lib/samagotchi/bridge_client.rb +20 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/config.rb +34 -3
- data/lib/samagotchi/engine.rb +6 -1
- data/lib/samagotchi/guardrail_wiring.rb +166 -2
- data/lib/samagotchi/guardrails/approval.rb +20 -0
- data/lib/samagotchi/guardrails/targets.rb +23 -2
- data/lib/samagotchi/guardrails/verdict.rb +23 -3
- data/lib/samagotchi/guardrails_command.rb +329 -0
- data/lib/samagotchi/hold_stop.rb +89 -0
- data/lib/samagotchi/memory_bundle/index_updater.rb +13 -0
- data/lib/samagotchi/prompt_warmup.rb +8 -3
- data/lib/samagotchi/session_access/contract.rb +1 -1
- data/lib/samagotchi/session_access/web_bridge_client.rb +9 -4
- data/lib/samagotchi/session_commands.rb +79 -2
- data/lib/samagotchi/session_manager.rb +3 -2
- data/lib/samagotchi/session_metrics.rb +7 -1
- data/lib/samagotchi/system_one/answer.rb +78 -0
- data/lib/samagotchi/system_one/approval_question.rb +113 -0
- data/lib/samagotchi/system_one/client.rb +89 -0
- data/lib/samagotchi/system_one/errors.rb +14 -0
- data/lib/samagotchi/system_one/fake_backend.rb +45 -0
- data/lib/samagotchi/system_one/http_backend.rb +129 -0
- data/lib/samagotchi/system_one/log.rb +133 -0
- data/lib/samagotchi/system_one/question.rb +86 -0
- data/lib/samagotchi/system_one/record.rb +83 -0
- data/lib/samagotchi/system_one/self_protect.rb +90 -0
- data/lib/samagotchi/system_one/similar.rb +69 -0
- data/lib/samagotchi/system_one.rb +25 -0
- data/lib/samagotchi/task_watch.rb +23 -5
- data/lib/samagotchi/terminal_ui/attached_loop.rb +145 -12
- data/lib/samagotchi/terminal_ui/event_renderer.rb +1 -0
- data/lib/samagotchi/terminal_ui/formatting.rb +14 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +3 -0
- data/lib/samagotchi/web/public/activity.js +10 -0
- data/lib/samagotchi/web/public/app.js +20 -1
- data/lib/samagotchi/web/public/apple-touch-icon.png +0 -0
- data/lib/samagotchi/web/public/composer_fold.js +8 -8
- data/lib/samagotchi/web/public/composer_grip.js +65 -22
- data/lib/samagotchi/web/public/composer_size.js +40 -5
- data/lib/samagotchi/web/public/data.js +24 -0
- data/lib/samagotchi/web/public/favicon.svg +1 -1
- data/lib/samagotchi/web/public/icons/chi-180.png +0 -0
- data/lib/samagotchi/web/public/icons/chi-192.png +0 -0
- data/lib/samagotchi/web/public/icons/chi-512.png +0 -0
- data/lib/samagotchi/web/public/index.html +21 -0
- data/lib/samagotchi/web/public/session_actions.js +39 -6
- data/lib/samagotchi/web/public/stop_sheet.js +126 -0
- data/lib/samagotchi/web/public/system_one_wrong.js +32 -0
- data/lib/samagotchi/web/public/timing.js +9 -5
- data/lib/samagotchi/web/public/turn_events.js +1 -0
- data/lib/samagotchi/web/public/turn_html.js +13 -2
- data/lib/samagotchi/web/session_access_routes.rb +9 -6
- data/lib/samagotchi/web/session_control.rb +62 -3
- data/lib/samagotchi/web/session_show.rb +35 -5
- data/lib/samagotchi/worker.rb +57 -5
- data/lib/samagotchi/worker_idle_exit.rb +111 -9
- metadata +17 -1
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { markSystemOneWrong } from "./data.js";
|
|
2
|
+
|
|
3
|
+
// The owner-only correction button on a System 1 auto-allowed tool row.
|
|
4
|
+
export function createSystemOneWrongAction({
|
|
5
|
+
sessionId,
|
|
6
|
+
mark = markSystemOneWrong,
|
|
7
|
+
alert = (text) => globalThis.alert(text),
|
|
8
|
+
}) {
|
|
9
|
+
return async (event) => {
|
|
10
|
+
const button = event.target.closest?.("button.system-one-wrong");
|
|
11
|
+
if (!button) return false;
|
|
12
|
+
|
|
13
|
+
event.preventDefault();
|
|
14
|
+
event.stopPropagation();
|
|
15
|
+
const id = sessionId();
|
|
16
|
+
if (!id || button.disabled) return false;
|
|
17
|
+
|
|
18
|
+
button.disabled = true;
|
|
19
|
+
try {
|
|
20
|
+
await mark(id, button.dataset.judgmentId);
|
|
21
|
+
button.textContent = "should have asked";
|
|
22
|
+
button.setAttribute("aria-pressed", "true");
|
|
23
|
+
button.classList.add("marked");
|
|
24
|
+
button.closest(".activity-row")?.classList.add("guardrail-wrong");
|
|
25
|
+
return true;
|
|
26
|
+
} catch (error) {
|
|
27
|
+
button.disabled = false;
|
|
28
|
+
alert(`Could not mark auto-allow: ${error.message}`);
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
}
|
|
@@ -274,18 +274,22 @@ function canceledSteps(g, record, tools) {
|
|
|
274
274
|
// A call's row from its tool record alone (no saved parts): the record's
|
|
275
275
|
// called_as and via too, so "(called as bash)" and "(via call_tool)" show
|
|
276
276
|
// after a reload, as live, and
|
|
277
|
-
// its no_match ("no match" on a grep that found nothing)
|
|
277
|
+
// its no_match ("no match" on a grep that found nothing) and System 1's
|
|
278
|
+
// auto-allow (guardrail: {rule, p}).
|
|
278
279
|
function recordRows(tools, iteration) {
|
|
279
280
|
return tools.filter((r) => Number(r.iteration) === iteration).map((r) => ({
|
|
280
281
|
key: `${iteration}:${r.call_index ?? 0}`, tool: r.tool, status: r.status, duration_ms: r.duration_ms,
|
|
281
282
|
...(r.called_as ? { called_as: r.called_as } : {}),
|
|
282
283
|
...(r.via ? { via: r.via } : {}),
|
|
283
|
-
...
|
|
284
|
+
...recordFlags(r),
|
|
284
285
|
}));
|
|
285
286
|
}
|
|
286
287
|
|
|
287
|
-
function
|
|
288
|
-
return
|
|
288
|
+
function recordFlags(record) {
|
|
289
|
+
return {
|
|
290
|
+
...(record?.no_match ? { no_match: true } : {}),
|
|
291
|
+
...(record?.guardrail ? { guardrail: record.guardrail } : {}),
|
|
292
|
+
};
|
|
289
293
|
}
|
|
290
294
|
|
|
291
295
|
// A turn whose saved messages carry their parts (the server's ?parts=1):
|
|
@@ -308,7 +312,7 @@ function partsTurn(g, items, record, tools, stepsOnly = false) {
|
|
|
308
312
|
const rows = Array.isArray(parts.tools)
|
|
309
313
|
? parts.tools.map((call, j) => {
|
|
310
314
|
const r = takeRecord(pool, call.tool);
|
|
311
|
-
return { key: `${iteration}:${j + 1}`, status: r?.status || "ok", duration_ms: r?.duration_ms, ...
|
|
315
|
+
return { key: `${iteration}:${j + 1}`, status: r?.status || "ok", duration_ms: r?.duration_ms, ...recordFlags(r), ...call };
|
|
312
316
|
})
|
|
313
317
|
: [];
|
|
314
318
|
const thinking = String(parts.thinking || "");
|
|
@@ -411,6 +411,7 @@ export function snapshotEvents({ current_turn: turn = null, queued = [], queued_
|
|
|
411
411
|
};
|
|
412
412
|
// A grep that found nothing: its row stays muted "no match".
|
|
413
413
|
if (part.no_match) completed.activity.no_match = true;
|
|
414
|
+
if (part.guardrail) completed.activity.guardrail = part.guardrail;
|
|
414
415
|
if (part.duration_ms != null) completed.duration_ms = part.duration_ms;
|
|
415
416
|
if (part.diff) completed.diff = part.diff;
|
|
416
417
|
Object.assign(completed, fields);
|
|
@@ -145,7 +145,8 @@ function tailClip(text) {
|
|
|
145
145
|
// part the user toggled (the raw command, the diff, the read lines) stays.
|
|
146
146
|
// +stop+: the live row's stop-task button (a node), after the title.
|
|
147
147
|
export function toolRowTemplate(row, opts = {}) {
|
|
148
|
-
|
|
148
|
+
const wrong = row.guardrail?.should_have_asked ? " guardrail-wrong" : "";
|
|
149
|
+
return html`<div class=${`activity-row${wrong}`} data-key=${row.key}>${toolRowInnerTemplate(row, opts)}</div>`;
|
|
149
150
|
}
|
|
150
151
|
|
|
151
152
|
export function toolRowInnerTemplate(row, { thumbs = () => nothing, stop = nothing } = {}) {
|
|
@@ -163,13 +164,23 @@ export function toolRowInnerTemplate(row, { thumbs = () => nothing, stop = nothi
|
|
|
163
164
|
// tool it was reached through (call_tool), dim.
|
|
164
165
|
const alias = row.called_as ? html`<span class="activity-alias">(called as ${row.called_as})</span>`
|
|
165
166
|
: row.via ? html`<span class="activity-alias">(via ${row.via})</span>` : nothing;
|
|
167
|
+
const auto = row.guardrail
|
|
168
|
+
? html`<span class="activity-auto" title=${`rule ${row.guardrail.rule}, p=${Number(row.guardrail.p).toFixed(2)}, allowed once`}>auto-allowed · System 1</span>`
|
|
169
|
+
: nothing;
|
|
170
|
+
const wrong = row.guardrail?.judgment_id
|
|
171
|
+
? row.guardrail.should_have_asked
|
|
172
|
+
? html`<span class="system-one-wrong marked"
|
|
173
|
+
title="System 1 was told this call should have been asked about">should have asked</span>`
|
|
174
|
+
: html`<button type="button" class="system-one-wrong" data-judgment-id=${row.guardrail.judgment_id}
|
|
175
|
+
title="Tell System 1 this call should have been asked about">should have asked</button>`
|
|
176
|
+
: nothing;
|
|
166
177
|
const duration = formatDuration(row.duration_ms);
|
|
167
178
|
const full = row.status === "running" ? "" : String(row.output || "").replace(/^\[[^\]]*\]\s*/, "");
|
|
168
179
|
const output = full
|
|
169
180
|
? html`<div class=${tailMode(row.tool, full) ? "activity-output tail" : "activity-output"} title=${full}>${inlineOutput(row.tool, full)}</div>`
|
|
170
181
|
: nothing;
|
|
171
182
|
const status = statusParts(row.status, { noMatch: !!row.no_match });
|
|
172
|
-
return html`${statusMarkTemplate(status)}<span class="activity-tool">${toolName(row)}</span>${params}${stop}${alias}${statusWordTemplate(status)}${duration ? html`<span class="activity-duration">${duration}</span>` : nothing}${commandBlockTemplate(row.view, row.title)}${llmEditTemplate(row)}${output}${full ? readLinesTemplate(row) : nothing}${row.images?.length ? thumbs(row.images) : nothing}${row.diff ? activityDiffTemplate(row.diff) : nothing}`;
|
|
183
|
+
return html`${statusMarkTemplate(status)}<span class="activity-tool">${toolName(row)}</span>${params}${stop}${alias}${statusWordTemplate(status)}${auto}${wrong}${duration ? html`<span class="activity-duration">${duration}</span>` : nothing}${commandBlockTemplate(row.view, row.title)}${llmEditTemplate(row)}${output}${full ? readLinesTemplate(row) : nothing}${row.images?.length ? thumbs(row.images) : nothing}${row.diff ? activityDiffTemplate(row.diff) : nothing}`;
|
|
173
184
|
}
|
|
174
185
|
|
|
175
186
|
// A prompt bubble's states and their badges: queued (waiting for its
|
|
@@ -304,22 +304,25 @@ module Samagotchi
|
|
|
304
304
|
{ epoch: sidecar.epoch, started_at: sidecar.started_at, version: sidecar.version, features: sidecar.features }
|
|
305
305
|
end
|
|
306
306
|
|
|
307
|
-
# POST /api/sessions/:id/exit {client_id, delete}: a remote attached
|
|
307
|
+
# POST /api/sessions/:id/exit {client_id, delete, stop}: a remote attached
|
|
308
308
|
# TUI's /exit (and the worker's half of /exit --delete and /archive),
|
|
309
309
|
# relayed to the live worker's exit (BridgeClient#request_exit; never
|
|
310
310
|
# wakes one). client_id is passed on only when it is an attached TUI's
|
|
311
311
|
# (tui:…): any other is dropped, and then every stream counts as a UI,
|
|
312
|
-
# which only keeps the worker up.
|
|
313
|
-
#
|
|
314
|
-
#
|
|
315
|
-
# worker_timeout.
|
|
312
|
+
# which only keeps the worker up. stop: true means the ask's "stop them
|
|
313
|
+
# and exit". 200 the Bridge's {status: exiting, session_id, discard,
|
|
314
|
+
# stopped}; 409 held with its {status, reason, tasks, hint, holds};
|
|
315
|
+
# 503 not_live, 501 not_supported (an older worker), 504 worker_timeout.
|
|
316
316
|
def handle_exit(req, id)
|
|
317
317
|
body = body_hash(req) or return refusal(400, "invalid_json", "invalid JSON body")
|
|
318
318
|
@session_class.load(id, state_dir: default_state_dir)
|
|
319
319
|
client_id = body["client_id"] if body["client_id"].is_a?(String) && body["client_id"].start_with?(ClientId::TUI_PREFIX)
|
|
320
320
|
delete = body["delete"] == true
|
|
321
|
+
options = { delete: delete }
|
|
322
|
+
options[:stop] = true if body["stop"] == "all"
|
|
323
|
+
options[:ask] = false if body["ask"] == false
|
|
321
324
|
|
|
322
|
-
request = ->(client) { client.request_exit(client_id: client_id,
|
|
325
|
+
request = ->(client) { client.request_exit(client_id: client_id, **options) }
|
|
323
326
|
with_message(relay(id, bridge_client(id), request, what: "the worker was not asked to exit", cant: "exit from here") do |reply|
|
|
324
327
|
case reply.status
|
|
325
328
|
when 200 then json_response(200, reply.json || { status: "exiting", session_id: id })
|
|
@@ -13,9 +13,10 @@ module Samagotchi
|
|
|
13
13
|
module Web
|
|
14
14
|
# The owner's routes that act on a session's worker: answer or dismiss
|
|
15
15
|
# its question, run a command, send a turn (and its images), cancel, stop
|
|
16
|
-
# a task, dequeue a queued turn,
|
|
17
|
-
#
|
|
18
|
-
#
|
|
16
|
+
# a task, dequeue a queued turn, what it could stop and stop it
|
|
17
|
+
# (handle_session_holds, handle_stop_holds — the page's Stop asks about
|
|
18
|
+
# these first), stop, restart, archive, unarchive and delete
|
|
19
|
+
# (handle_question_answer … handle_delete), with the refusals they answer (owned_by_repl, owned_by_tui, archive_refused). Included in App:
|
|
19
20
|
# its ROUTES name the handlers, and its helpers (json_response,
|
|
20
21
|
# error_response, parse_json, request_body, relay, not_live,
|
|
21
22
|
# bridge_error, reply_detail, worker_timeout, bridge_client,
|
|
@@ -107,6 +108,28 @@ module Samagotchi
|
|
|
107
108
|
end
|
|
108
109
|
end
|
|
109
110
|
|
|
111
|
+
# The owner's correction for a System 1 auto-allow. The Bridge checks
|
|
112
|
+
# that the judgment belongs to this worker's session.
|
|
113
|
+
def handle_system_one_wrong(req, id)
|
|
114
|
+
body = parse_json(request_body(req))
|
|
115
|
+
return error_response(400, "invalid_json", "invalid JSON body") unless body.is_a?(Hash)
|
|
116
|
+
|
|
117
|
+
judgment_id = body["id"].to_s.strip
|
|
118
|
+
return error_response(400, "missing_fields", "id is required") if judgment_id.empty?
|
|
119
|
+
|
|
120
|
+
request = ->(client) { client.mark_system_one_wrong(judgment_id: judgment_id) }
|
|
121
|
+
relay(id, bridge_client(id), request, what: "the System 1 judgment was not labelled",
|
|
122
|
+
cant: "label System 1 judgments") do |reply|
|
|
123
|
+
case reply.status
|
|
124
|
+
when 200 then json_response(200, { status: "labelled", id: judgment_id })
|
|
125
|
+
when 404
|
|
126
|
+
error_response(404, "unknown_judgment", "unknown System 1 judgment") if reply.json&.dig("error") == "unknown_judgment"
|
|
127
|
+
when 409 then error_response(409, "already_labelled", "System 1 judgment already has a label")
|
|
128
|
+
when 400 then error_response(400, "invalid_judgment", reply_detail(reply, "invalid System 1 judgment"))
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
110
133
|
# A session command typed in the composer (/model, /models, !rollback,
|
|
111
134
|
# !cmd, /continue): the worker runs it (woken as for a turn) and every
|
|
112
135
|
# UI gets its :command_ran. Needs the worker's Bridge.
|
|
@@ -337,6 +360,42 @@ module Samagotchi
|
|
|
337
360
|
end
|
|
338
361
|
end
|
|
339
362
|
|
|
363
|
+
# GET /api/sessions/:id/holds: what this session's worker could stop
|
|
364
|
+
# right now (its Bridge's GET holds: reminders, the background tasks a
|
|
365
|
+
# finish wake watches that still run, the delegate children with a live
|
|
366
|
+
# worker), so the page's Stop can ask about them before TERMed-ing the
|
|
367
|
+
# worker. 200 {session_id, holds: []} — always a list: with no live
|
|
368
|
+
# worker, a worker from before the route, or one that didn't answer
|
|
369
|
+
# there is nothing to ask about, and Stop stays one tap as it was.
|
|
370
|
+
def handle_session_holds(_req, id)
|
|
371
|
+
@session_class.load(id, state_dir: default_state_dir)
|
|
372
|
+
client = bridge_client(id)
|
|
373
|
+
return json_response(200, { session_id: id, holds: [] }) unless client
|
|
374
|
+
|
|
375
|
+
status, json = client.get("holds")
|
|
376
|
+
holds = status == 200 && json.is_a?(Hash) ? json["holds"] : nil
|
|
377
|
+
json_response(200, { session_id: id, holds: holds.is_a?(Array) ? holds : [] })
|
|
378
|
+
rescue SystemCallError, IOError, SocketError
|
|
379
|
+
json_response(200, { session_id: id, holds: [] })
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
# POST /api/sessions/:id/holds/stop: the sheet's "Stop all" — the worker
|
|
383
|
+
# stops everything the user can stop (Bridge#handle_holds_stop) before
|
|
384
|
+
# today's Stop TERMs it. 200 {status: "stopped", session_id, stopped:
|
|
385
|
+
# {reminders:, tasks:, delegates:}}; 503 no live worker, 501 a worker
|
|
386
|
+
# from before the route, 504/502 as every relay (the page says which
|
|
387
|
+
# thing it could not stop and stops the session anyway).
|
|
388
|
+
def handle_stop_holds(_req, id)
|
|
389
|
+
@session_class.load(id, state_dir: default_state_dir)
|
|
390
|
+
request = ->(client) { client.stop_holds }
|
|
391
|
+
relay(id, bridge_client(id), request, what: "nothing was stopped", cant: "stop what the session runs") do |reply|
|
|
392
|
+
next unless reply.status == 200
|
|
393
|
+
|
|
394
|
+
stopped = reply.json&.dig("stopped")
|
|
395
|
+
json_response(200, { status: "stopped", session_id: id, stopped: stopped || {} })
|
|
396
|
+
end
|
|
397
|
+
end
|
|
398
|
+
|
|
340
399
|
# POST /api/sessions/:id/stop {wait?}: 200 {status: "stopped",
|
|
341
400
|
# session_id, released}: released, whether the worker let go of the
|
|
342
401
|
# session within the wait (STOP_WAIT_SECONDS, or the body's seconds up
|
|
@@ -8,9 +8,11 @@ require_relative "../answer_refs"
|
|
|
8
8
|
require_relative "../answer_tail"
|
|
9
9
|
require_relative "../output_formatter"
|
|
10
10
|
require_relative "../question_asked"
|
|
11
|
+
require_relative "../guardrails/approvals"
|
|
11
12
|
require_relative "../session_commands"
|
|
12
13
|
require_relative "../session_metrics"
|
|
13
14
|
require_relative "../session_setting"
|
|
15
|
+
require_relative "../system_one/log"
|
|
14
16
|
|
|
15
17
|
module Samagotchi
|
|
16
18
|
module Web
|
|
@@ -300,7 +302,7 @@ module Samagotchi
|
|
|
300
302
|
persisted
|
|
301
303
|
end
|
|
302
304
|
turns = Array(source["turn_records"])
|
|
303
|
-
timing_from(source, turns, Array(source["tool_records"]), live: live,
|
|
305
|
+
timing_from(source, turns, Array(source["tool_records"]), session_id: session_id, live: live,
|
|
304
306
|
turn_count: (live && turn_count(live_metrics)) || turns.size)
|
|
305
307
|
end
|
|
306
308
|
|
|
@@ -321,13 +323,13 @@ module Samagotchi
|
|
|
321
323
|
# An older worker's metrics hold every record (before they held the
|
|
322
324
|
# recent ones only, never more than LIVE_RECORDS_CAP turns).
|
|
323
325
|
turns, tools = newest_turn_records(turns, tools, live_metrics["active_turn"]) if turns.size > SessionMetrics::LIVE_RECORDS_CAP
|
|
324
|
-
return timing_from(live_metrics, turns, tools, live: true, turn_count: count)
|
|
326
|
+
return timing_from(live_metrics, turns, tools, session_id: session_id, live: true, turn_count: count)
|
|
325
327
|
end
|
|
326
328
|
|
|
327
329
|
persisted = read_analytics(session_id)
|
|
328
330
|
all = Array(persisted["turn_records"])
|
|
329
331
|
turns, tools = newest_turn_records(all, Array(persisted["tool_records"]), nil)
|
|
330
|
-
timing_from(persisted, turns, tools, live: false, turn_count: all.size)
|
|
332
|
+
timing_from(persisted, turns, tools, session_id: session_id, live: false, turn_count: all.size)
|
|
331
333
|
end
|
|
332
334
|
|
|
333
335
|
# The finished turns a live worker counts: its metrics' turns (the
|
|
@@ -349,7 +351,7 @@ module Samagotchi
|
|
|
349
351
|
[kept, tools.select { |r| r.is_a?(Hash) && ids.include?(r["turn_id"]) }]
|
|
350
352
|
end
|
|
351
353
|
|
|
352
|
-
def timing_from(source, turn_records, tool_records, live:, turn_count:)
|
|
354
|
+
def timing_from(source, turn_records, tool_records, session_id:, live:, turn_count:)
|
|
353
355
|
started_at = source["started_at"]
|
|
354
356
|
last_activity_at = source["last_activity_at"]
|
|
355
357
|
{
|
|
@@ -357,7 +359,7 @@ module Samagotchi
|
|
|
357
359
|
last_activity_at: last_activity_at,
|
|
358
360
|
session_duration_ms: session_duration_ms(started_at, last_activity_at, active: live),
|
|
359
361
|
turn_records: turn_records,
|
|
360
|
-
tool_records: tool_records,
|
|
362
|
+
tool_records: system_one_labelled_records(session_id, tool_records),
|
|
361
363
|
turn_count: turn_count,
|
|
362
364
|
active_turn: source["active_turn"],
|
|
363
365
|
active_tools: Array(source["active_tools"]),
|
|
@@ -376,6 +378,34 @@ module Samagotchi
|
|
|
376
378
|
}
|
|
377
379
|
end
|
|
378
380
|
|
|
381
|
+
# A saved analytics record knows the judgment id, not the later label:
|
|
382
|
+
# the judgments log remains the source of truth across reloads.
|
|
383
|
+
def system_one_labelled_records(session_id, records)
|
|
384
|
+
ids = records.filter_map do |record|
|
|
385
|
+
record.dig("guardrail", "judgment_id") if record.is_a?(Hash) && record["guardrail"].is_a?(Hash)
|
|
386
|
+
end.to_h { |id| [id, true] }
|
|
387
|
+
return records if ids.empty?
|
|
388
|
+
|
|
389
|
+
log = SystemOne::Log.new(dir: Guardrails::Approvals.dir_for(default_state_dir))
|
|
390
|
+
return records unless File.file?(log.path)
|
|
391
|
+
|
|
392
|
+
labelled = log.entries.each_with_object({}) do |entry, result|
|
|
393
|
+
next unless ids[entry["id"]] && entry["session_id"] == session_id && entry["decision"] == "auto" &&
|
|
394
|
+
entry["answer"] == "should_have_asked"
|
|
395
|
+
|
|
396
|
+
result[entry["id"]] = true
|
|
397
|
+
end
|
|
398
|
+
return records if labelled.empty?
|
|
399
|
+
|
|
400
|
+
records.map do |record|
|
|
401
|
+
guardrail = record.is_a?(Hash) && record["guardrail"]
|
|
402
|
+
judgment_id = guardrail.is_a?(Hash) && guardrail["judgment_id"]
|
|
403
|
+
next record unless labelled[judgment_id]
|
|
404
|
+
|
|
405
|
+
record.merge("guardrail" => guardrail.merge("should_have_asked" => true))
|
|
406
|
+
end
|
|
407
|
+
end
|
|
408
|
+
|
|
379
409
|
def read_analytics(session_id)
|
|
380
410
|
AnalyticsFile.read(@session_class.session_dir(session_id, state_dir: default_state_dir)) || {}
|
|
381
411
|
end
|
data/lib/samagotchi/worker.rb
CHANGED
|
@@ -13,6 +13,8 @@ require_relative "turn_note"
|
|
|
13
13
|
require_relative "steer"
|
|
14
14
|
require_relative "turn_origin"
|
|
15
15
|
require_relative "worker_idle_exit"
|
|
16
|
+
require_relative "hold_stop"
|
|
17
|
+
require_relative "children_status"
|
|
16
18
|
require_relative "worker_wakes"
|
|
17
19
|
require_relative "worker_inbound"
|
|
18
20
|
require_relative "worker_signals"
|
|
@@ -210,6 +212,11 @@ module Samagotchi
|
|
|
210
212
|
end
|
|
211
213
|
end
|
|
212
214
|
|
|
215
|
+
# The step that stops what the user can stop (the reminders, the watched
|
|
216
|
+
# tasks and the delegate children the exit ask lists), and what the last
|
|
217
|
+
# stop-all exit stopped. /exit's 2 uses it; the web's Stop will too.
|
|
218
|
+
attr_reader :hold_stop, :stopped_holds
|
|
219
|
+
|
|
213
220
|
private
|
|
214
221
|
|
|
215
222
|
# @return [Symbol] what #run returns
|
|
@@ -330,7 +337,13 @@ module Samagotchi
|
|
|
330
337
|
on_command: method(:on_command),
|
|
331
338
|
on_exit_request: method(:exit_request),
|
|
332
339
|
# Decided again as it leaves (a note may still come in).
|
|
333
|
-
exit_discards: method(:empty_session?)
|
|
340
|
+
exit_discards: method(:empty_session?),
|
|
341
|
+
# What a stop-all /exit stopped, for its 200.
|
|
342
|
+
exit_stopped: method(:stopped_holds),
|
|
343
|
+
# chi web's Stop asks about these before TERMed-ing
|
|
344
|
+
# the worker (its own holds routes).
|
|
345
|
+
on_holds: -> { @idle_exit&.stoppable_holds },
|
|
346
|
+
on_holds_stop: -> { @hold_stop&.stop_all })
|
|
334
347
|
# What failed to load and what plugins showed as they loaded, now that
|
|
335
348
|
# a UI can be there (a late one gets them in the snapshot).
|
|
336
349
|
@engine.announce_load_events!
|
|
@@ -346,8 +359,25 @@ module Samagotchi
|
|
|
346
359
|
# finished since the pass looked holds too, until the next pass
|
|
347
360
|
# wakes for it (#run_task_wake).
|
|
348
361
|
watched_tasks: -> { TaskWatch.mode == "wake" ? @task_wakes.watch.holding : [] },
|
|
349
|
-
watched_running: -> { TaskWatch.mode == "wake" ? @task_wakes.watch.running : [] }
|
|
362
|
+
watched_running: -> { TaskWatch.mode == "wake" ? @task_wakes.watch.running : [] },
|
|
363
|
+
# Never a hold (a child is its own worker), but the exit ask lists
|
|
364
|
+
# them so "stop them and exit" stops them too.
|
|
365
|
+
delegates: method(:live_delegates)
|
|
350
366
|
)
|
|
367
|
+
@hold_stop = HoldStop.new(engine: @engine, task_watch: @task_wakes.watch, delegates: method(:live_delegates),
|
|
368
|
+
state_dir: @state_dir)
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
# The delegate children of this session that are running a turn or have
|
|
372
|
+
# a live worker. ChildrenStatus.of scans the session files; this runs on
|
|
373
|
+
# an exit request, not every pass.
|
|
374
|
+
# @return [Array<ChildrenStatus::Child>]
|
|
375
|
+
def live_delegates
|
|
376
|
+
ChildrenStatus.of(@session_id, state_dir: @state_dir)
|
|
377
|
+
.select { |child| child.delegate && (child.state == "running" || child.live) }
|
|
378
|
+
rescue StandardError => e
|
|
379
|
+
Log.warn(:worker, "delegate_look_failed", error: e.class.name, msg: e.message)
|
|
380
|
+
[]
|
|
351
381
|
end
|
|
352
382
|
|
|
353
383
|
# What runs beside the loop until it leaves.
|
|
@@ -1418,10 +1448,18 @@ module Samagotchi
|
|
|
1418
1448
|
# +delete+: the session is deleted after (/exit --delete), so no recap.
|
|
1419
1449
|
# +restart+: a new worker takes over (on the newest chi installed), so
|
|
1420
1450
|
# WorkerIdleExit#hold_for_restart's rules, and no recap or discard.
|
|
1451
|
+
# +stop+: the exit ask's "stop them and exit" — stop every hold the user
|
|
1452
|
+
# can stop before leaving; a hold they can't stop refuses the request
|
|
1453
|
+
# (WorkerIdleExit#request_hold checks again, so one that appeared
|
|
1454
|
+
# since the ask is refused too).
|
|
1455
|
+
# +ask+: this client asks the user what to do with what still runs, so the
|
|
1456
|
+
# delegate children are part of the list. An exit that deletes the
|
|
1457
|
+
# session, or one a UI can't ask about (/archive), leaves them out:
|
|
1458
|
+
# they never hold the worker up on their own.
|
|
1421
1459
|
# @return [WorkerIdleExit::Hold, nil] what keeps the worker up (for
|
|
1422
1460
|
# :background_tasks, the watched tasks still running), nil when it
|
|
1423
1461
|
# will leave
|
|
1424
|
-
def exit_request(client_id, delete: false, restart: false)
|
|
1462
|
+
def exit_request(client_id, delete: false, restart: false, stop: false, ask: true)
|
|
1425
1463
|
# The Bridge serves before the idle-exit policy exists.
|
|
1426
1464
|
return WorkerIdleExit::Hold.new(reason: :starting) unless @idle_exit
|
|
1427
1465
|
|
|
@@ -1430,10 +1468,24 @@ module Samagotchi
|
|
|
1430
1468
|
return WorkerIdleExit::Hold.new(reason: hold) if hold
|
|
1431
1469
|
else
|
|
1432
1470
|
# /exit names the tasks it waits for (D3), from the one look that found them.
|
|
1433
|
-
|
|
1434
|
-
|
|
1471
|
+
# The ask covers the delegate children too, but only for a plain /exit: an
|
|
1472
|
+
# exit that deletes the session, or one a UI won't ask about, ignores them.
|
|
1473
|
+
hold = @idle_exit.request_hold(requester: client_id, hint: TASK_STOP_HINT, ask: ask && !delete)
|
|
1474
|
+
if hold
|
|
1475
|
+
# A hold the user can't stop here: no ask, today's refusal.
|
|
1476
|
+
return hold unless hold.ask?
|
|
1477
|
+
# The ask's 409: the list, for the terminal to ask with.
|
|
1478
|
+
return hold unless stop
|
|
1479
|
+
|
|
1480
|
+
@stopped_holds = @hold_stop.stop_all
|
|
1481
|
+
# An unstoppable hold may have arrived while stopping (a child stopped
|
|
1482
|
+
# here is no longer counted).
|
|
1483
|
+
recheck = @idle_exit.request_hold(requester: client_id, hint: TASK_STOP_HINT)
|
|
1484
|
+
return recheck if recheck
|
|
1485
|
+
end
|
|
1435
1486
|
end
|
|
1436
1487
|
|
|
1488
|
+
@stopped_holds = nil unless stop
|
|
1437
1489
|
@exit_requested = true
|
|
1438
1490
|
@exit_requested_by = client_id
|
|
1439
1491
|
@exit_deletes = delete && !restart
|
|
@@ -27,12 +27,27 @@ module Samagotchi
|
|
|
27
27
|
# What keeps the worker up when a client asks it to exit (Worker#exit_request,
|
|
28
28
|
# the Bridge's 409): +reason+, a #hold_for_request (or #hold_for_restart)
|
|
29
29
|
# Symbol, or :starting before this policy exists; for :background_tasks
|
|
30
|
-
# the watched tasks still running ({id:, command:}) and a
|
|
31
|
-
# stopping them
|
|
32
|
-
|
|
33
|
-
|
|
30
|
+
# the watched tasks still running ({id:, command:, age_seconds:}) and a
|
|
31
|
+
# +hint+ on stopping them; +holds+ what the user can stop from the exit
|
|
32
|
+
# ask (#StoppableHold), empty when the reason is something they can't.
|
|
33
|
+
Hold = Data.define(:reason, :tasks, :hint, :holds) do
|
|
34
|
+
def initialize(reason:, tasks: nil, hint: nil, holds: []) = super
|
|
35
|
+
|
|
36
|
+
# Held only by things the user can stop: /exit asks what to do with them
|
|
37
|
+
# rather than refusing.
|
|
38
|
+
def ask? = !holds.empty?
|
|
34
39
|
end
|
|
35
40
|
|
|
41
|
+
# One kind of hold the exit ask offers to stop: +kind+ (:reminders,
|
|
42
|
+
# :background_tasks or :delegates) and its +items+ (#HoldItem). The 409's
|
|
43
|
+
# +holds+ is this in JSON: `{kind:, items: [{id:, label:, detail:}]}`.
|
|
44
|
+
StoppableHold = Data.define(:kind, :items)
|
|
45
|
+
|
|
46
|
+
# One entry of a hold: +id+ (a reminder's name, a task id, a child session
|
|
47
|
+
# id), +label+ (the command, the child's title) and +detail+ ("every 20m",
|
|
48
|
+
# "2m", "running a turn").
|
|
49
|
+
HoldItem = Data.define(:id, :label, :detail)
|
|
50
|
+
|
|
36
51
|
# @param engine [Engine] #turn_running?, #last_activity_at, #reminder_store
|
|
37
52
|
# @param bridge [Bridge, nil] #open_streams, #last_client_activity_at; nil
|
|
38
53
|
# when the worker's Bridge failed to start
|
|
@@ -46,10 +61,13 @@ module Samagotchi
|
|
|
46
61
|
# @param watched_running [#call] those of them still running
|
|
47
62
|
# (TaskWatch#running): they hold /exit (one that finished is a note
|
|
48
63
|
# as the worker leaves)
|
|
64
|
+
# @param delegates [#call] the delegate children this session has running
|
|
65
|
+
# or with a live worker (ChildrenStatus::Child): they never hold the
|
|
66
|
+
# exit, but the exit ask lists them so "stop them" stops them too
|
|
49
67
|
# @param clock [#call] monotonic seconds, the Engine's and Bridge's clock
|
|
50
68
|
def initialize(engine:, bridge:, timeout_minutes:, input_pending:, awaiting_continue: -> { false },
|
|
51
69
|
running_tasks: -> { [] }, watched_tasks: -> { [] }, watched_running: -> { [] },
|
|
52
|
-
clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
|
|
70
|
+
delegates: -> { [] }, clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
|
|
53
71
|
@engine = engine
|
|
54
72
|
@bridge = bridge
|
|
55
73
|
@timeout = timeout_minutes.to_f * 60
|
|
@@ -58,6 +76,7 @@ module Samagotchi
|
|
|
58
76
|
@running_tasks = running_tasks
|
|
59
77
|
@watched_tasks = watched_tasks
|
|
60
78
|
@watched_running = watched_running
|
|
79
|
+
@delegates = delegates
|
|
61
80
|
@clock = clock
|
|
62
81
|
@started_at = clock.call
|
|
63
82
|
end
|
|
@@ -96,21 +115,50 @@ module Samagotchi
|
|
|
96
115
|
# :client_connected, :reminders or :background_tasks
|
|
97
116
|
def hold_for_request(requester:, streams: true) = request_hold(requester: requester, streams: streams)&.reason
|
|
98
117
|
|
|
118
|
+
# What the user could stop in this worker right now, whatever else keeps
|
|
119
|
+
# it up: its reminders, the watched background tasks still running, and
|
|
120
|
+
# (for a UI that asks about them, +include_delegates+) the delegate
|
|
121
|
+
# children with a live worker. #request_hold's +holds+ is this, and the
|
|
122
|
+
# web's Stop asks with it before TERMed-ing the worker — a turn running
|
|
123
|
+
# or another UI attached does not hide it, since Stop cancels the turn
|
|
124
|
+
# anyway (docs/sessions.md, "Stopping a worker").
|
|
125
|
+
# @return [Array<StoppableHold>]
|
|
126
|
+
def stoppable_holds(running: @watched_running.call, include_delegates: true)
|
|
127
|
+
holds = []
|
|
128
|
+
reminders = reminder_items
|
|
129
|
+
tasks = running.map do |task|
|
|
130
|
+
HoldItem.new(id: task.id, label: task.command, detail: age_words(task.age_seconds))
|
|
131
|
+
end
|
|
132
|
+
children = include_delegates ? delegate_items : []
|
|
133
|
+
|
|
134
|
+
holds << StoppableHold.new(kind: :reminders, items: reminders) if reminders.any?
|
|
135
|
+
holds << StoppableHold.new(kind: :background_tasks, items: tasks) if tasks.any?
|
|
136
|
+
holds << StoppableHold.new(kind: :delegates, items: children) if children.any?
|
|
137
|
+
holds
|
|
138
|
+
end
|
|
139
|
+
|
|
99
140
|
# #hold_for_request as a Hold: for :background_tasks, the watched tasks
|
|
100
141
|
# still running, read once (TaskWatch#running looks at each task's
|
|
101
|
-
# process), with +hint+.
|
|
142
|
+
# process), with +hint+. +ask+: the client will ask the user about what
|
|
143
|
+
# still runs (an attached terminal's plain `/exit`), so the delegate
|
|
144
|
+
# children this session has running or with a live worker are listed too;
|
|
145
|
+
# they are never a hold on their own, so a request without +ask: true
|
|
146
|
+
# (an exit that deletes the session, the worker's last look before
|
|
147
|
+
# leaving) ignores them.
|
|
102
148
|
# @return [Hold, nil]
|
|
103
|
-
def request_hold(requester:, streams: true, hint: nil)
|
|
149
|
+
def request_hold(requester:, streams: true, hint: nil, ask: false)
|
|
104
150
|
reason = if @engine.turn_running? then :turn_running
|
|
105
151
|
elsif @input_pending.call then :input_queued
|
|
106
152
|
elsif @awaiting_continue.call then :continue_offered
|
|
107
153
|
elsif streams && @bridge && @bridge.open_streams_except(requester).positive? then :client_connected
|
|
108
|
-
elsif @engine.reminder_store&.any? then :reminders
|
|
109
154
|
end
|
|
110
155
|
return Hold.new(reason: reason) if reason
|
|
111
156
|
|
|
112
157
|
running = @watched_running.call
|
|
113
|
-
|
|
158
|
+
holds = stoppable_holds(running: running, include_delegates: ask)
|
|
159
|
+
return nil if holds.empty?
|
|
160
|
+
|
|
161
|
+
Hold.new(reason: holds.first.kind, tasks: running.map(&:to_h), hint: hint, holds: holds)
|
|
114
162
|
end
|
|
115
163
|
|
|
116
164
|
# What keeps the worker from restarting now (a client asked: POST /exit
|
|
@@ -143,5 +191,59 @@ module Samagotchi
|
|
|
143
191
|
last = [@started_at, @engine.last_activity_at, @bridge&.last_client_activity_at].compact.max
|
|
144
192
|
@clock.call - last
|
|
145
193
|
end
|
|
194
|
+
|
|
195
|
+
private
|
|
196
|
+
|
|
197
|
+
# One item per reminder the Engine holds. Every reminder ReminderStore
|
|
198
|
+
# registers is periodic (interval_minutes, 1..1440), so the detail is its
|
|
199
|
+
# schedule — "every 20m", "every 1h" for a whole number of hours — plus
|
|
200
|
+
# ", due now" when its next fire has already come. An entry written
|
|
201
|
+
# without an interval (a store from another chi, a hand-edited one) has no
|
|
202
|
+
# detail: the label alone.
|
|
203
|
+
def reminder_items
|
|
204
|
+
store = @engine.reminder_store
|
|
205
|
+
return [] unless store
|
|
206
|
+
|
|
207
|
+
store.reminders.values.map do |reminder|
|
|
208
|
+
HoldItem.new(id: reminder[:name], label: reminder[:name].to_s, detail: schedule_words(reminder))
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def schedule_words(reminder)
|
|
213
|
+
minutes = Integer(reminder[:interval_minutes], exception: false)
|
|
214
|
+
return nil if minutes.nil? || minutes <= 0
|
|
215
|
+
|
|
216
|
+
words = (minutes % 60).zero? ? "every #{minutes / 60}h" : "every #{minutes}m"
|
|
217
|
+
next_fire = reminder[:next_fire_at]
|
|
218
|
+
words = "#{words}, due now" if next_fire && @clock.call >= next_fire
|
|
219
|
+
words
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# One item per delegate child still running or with a live worker.
|
|
223
|
+
def delegate_items
|
|
224
|
+
@delegates.call.map do |child|
|
|
225
|
+
HoldItem.new(id: child.id, label: child.title.to_s, detail: child_state_words(child))
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# What a child is doing, as the ask says it. #live_delegates takes only
|
|
230
|
+
# ones running a turn or with a worker of their own.
|
|
231
|
+
def child_state_words(child)
|
|
232
|
+
case child.state
|
|
233
|
+
when "running" then "running a turn"
|
|
234
|
+
when "waiting" then "waiting for an answer"
|
|
235
|
+
else "a live worker"
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# How long a task has been running, as the ask words it ("2m", "42s");
|
|
240
|
+
# nil when its record has no start time.
|
|
241
|
+
def age_words(seconds)
|
|
242
|
+
return nil if seconds.nil?
|
|
243
|
+
return "#{seconds / 3600}h" if seconds >= 3600
|
|
244
|
+
return "#{seconds / 60}m" if seconds >= 60
|
|
245
|
+
|
|
246
|
+
"#{seconds}s"
|
|
247
|
+
end
|
|
146
248
|
end
|
|
147
249
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: samagotchi
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.117.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Dmitry Dedov
|
|
@@ -362,6 +362,8 @@ files:
|
|
|
362
362
|
- lib/samagotchi/guardrails/shell_paths.rb
|
|
363
363
|
- lib/samagotchi/guardrails/targets.rb
|
|
364
364
|
- lib/samagotchi/guardrails/verdict.rb
|
|
365
|
+
- lib/samagotchi/guardrails_command.rb
|
|
366
|
+
- lib/samagotchi/hold_stop.rb
|
|
365
367
|
- lib/samagotchi/hook_question.rb
|
|
366
368
|
- lib/samagotchi/hook_routes.rb
|
|
367
369
|
- lib/samagotchi/hooks.rb
|
|
@@ -576,6 +578,18 @@ files:
|
|
|
576
578
|
- lib/samagotchi/steer.rb
|
|
577
579
|
- lib/samagotchi/steer_cut.rb
|
|
578
580
|
- lib/samagotchi/steer_cut_after.rb
|
|
581
|
+
- lib/samagotchi/system_one.rb
|
|
582
|
+
- lib/samagotchi/system_one/answer.rb
|
|
583
|
+
- lib/samagotchi/system_one/approval_question.rb
|
|
584
|
+
- lib/samagotchi/system_one/client.rb
|
|
585
|
+
- lib/samagotchi/system_one/errors.rb
|
|
586
|
+
- lib/samagotchi/system_one/fake_backend.rb
|
|
587
|
+
- lib/samagotchi/system_one/http_backend.rb
|
|
588
|
+
- lib/samagotchi/system_one/log.rb
|
|
589
|
+
- lib/samagotchi/system_one/question.rb
|
|
590
|
+
- lib/samagotchi/system_one/record.rb
|
|
591
|
+
- lib/samagotchi/system_one/self_protect.rb
|
|
592
|
+
- lib/samagotchi/system_one/similar.rb
|
|
579
593
|
- lib/samagotchi/system_prompt.rb
|
|
580
594
|
- lib/samagotchi/task_finish_note.rb
|
|
581
595
|
- lib/samagotchi/task_watch.rb
|
|
@@ -811,11 +825,13 @@ files:
|
|
|
811
825
|
- lib/samagotchi/web/public/stage_view.js
|
|
812
826
|
- lib/samagotchi/web/public/stage_watch.js
|
|
813
827
|
- lib/samagotchi/web/public/start_page.js
|
|
828
|
+
- lib/samagotchi/web/public/stop_sheet.js
|
|
814
829
|
- lib/samagotchi/web/public/storage.js
|
|
815
830
|
- lib/samagotchi/web/public/stream_handlers.js
|
|
816
831
|
- lib/samagotchi/web/public/strip.js
|
|
817
832
|
- lib/samagotchi/web/public/strip_grip.js
|
|
818
833
|
- lib/samagotchi/web/public/sw.js
|
|
834
|
+
- lib/samagotchi/web/public/system_one_wrong.js
|
|
819
835
|
- lib/samagotchi/web/public/tally.js
|
|
820
836
|
- lib/samagotchi/web/public/task_stop.js
|
|
821
837
|
- lib/samagotchi/web/public/thinking_chip.js
|