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.
Files changed (69) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +76 -1
  3. data/bin/chi +2 -0
  4. data/docs/cli.md +33 -2
  5. data/docs/configuration.md +13 -0
  6. data/docs/guardrails.md +188 -1
  7. data/docs/internals/background-tasks.md +1 -1
  8. data/docs/sessions.md +3 -3
  9. data/lib/samagotchi/bridge/turn_accumulator.rb +4 -0
  10. data/lib/samagotchi/bridge.rb +114 -14
  11. data/lib/samagotchi/bridge_client.rb +20 -1
  12. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  13. data/lib/samagotchi/config.rb +34 -3
  14. data/lib/samagotchi/engine.rb +6 -1
  15. data/lib/samagotchi/guardrail_wiring.rb +166 -2
  16. data/lib/samagotchi/guardrails/approval.rb +20 -0
  17. data/lib/samagotchi/guardrails/targets.rb +23 -2
  18. data/lib/samagotchi/guardrails/verdict.rb +23 -3
  19. data/lib/samagotchi/guardrails_command.rb +329 -0
  20. data/lib/samagotchi/hold_stop.rb +89 -0
  21. data/lib/samagotchi/memory_bundle/index_updater.rb +13 -0
  22. data/lib/samagotchi/prompt_warmup.rb +8 -3
  23. data/lib/samagotchi/session_access/contract.rb +1 -1
  24. data/lib/samagotchi/session_access/web_bridge_client.rb +9 -4
  25. data/lib/samagotchi/session_commands.rb +79 -2
  26. data/lib/samagotchi/session_manager.rb +3 -2
  27. data/lib/samagotchi/session_metrics.rb +7 -1
  28. data/lib/samagotchi/system_one/answer.rb +78 -0
  29. data/lib/samagotchi/system_one/approval_question.rb +113 -0
  30. data/lib/samagotchi/system_one/client.rb +89 -0
  31. data/lib/samagotchi/system_one/errors.rb +14 -0
  32. data/lib/samagotchi/system_one/fake_backend.rb +45 -0
  33. data/lib/samagotchi/system_one/http_backend.rb +129 -0
  34. data/lib/samagotchi/system_one/log.rb +133 -0
  35. data/lib/samagotchi/system_one/question.rb +86 -0
  36. data/lib/samagotchi/system_one/record.rb +83 -0
  37. data/lib/samagotchi/system_one/self_protect.rb +90 -0
  38. data/lib/samagotchi/system_one/similar.rb +69 -0
  39. data/lib/samagotchi/system_one.rb +25 -0
  40. data/lib/samagotchi/task_watch.rb +23 -5
  41. data/lib/samagotchi/terminal_ui/attached_loop.rb +145 -12
  42. data/lib/samagotchi/terminal_ui/event_renderer.rb +1 -0
  43. data/lib/samagotchi/terminal_ui/formatting.rb +14 -0
  44. data/lib/samagotchi/version.rb +1 -1
  45. data/lib/samagotchi/web/app.rb +3 -0
  46. data/lib/samagotchi/web/public/activity.js +10 -0
  47. data/lib/samagotchi/web/public/app.js +20 -1
  48. data/lib/samagotchi/web/public/apple-touch-icon.png +0 -0
  49. data/lib/samagotchi/web/public/composer_fold.js +8 -8
  50. data/lib/samagotchi/web/public/composer_grip.js +65 -22
  51. data/lib/samagotchi/web/public/composer_size.js +40 -5
  52. data/lib/samagotchi/web/public/data.js +24 -0
  53. data/lib/samagotchi/web/public/favicon.svg +1 -1
  54. data/lib/samagotchi/web/public/icons/chi-180.png +0 -0
  55. data/lib/samagotchi/web/public/icons/chi-192.png +0 -0
  56. data/lib/samagotchi/web/public/icons/chi-512.png +0 -0
  57. data/lib/samagotchi/web/public/index.html +21 -0
  58. data/lib/samagotchi/web/public/session_actions.js +39 -6
  59. data/lib/samagotchi/web/public/stop_sheet.js +126 -0
  60. data/lib/samagotchi/web/public/system_one_wrong.js +32 -0
  61. data/lib/samagotchi/web/public/timing.js +9 -5
  62. data/lib/samagotchi/web/public/turn_events.js +1 -0
  63. data/lib/samagotchi/web/public/turn_html.js +13 -2
  64. data/lib/samagotchi/web/session_access_routes.rb +9 -6
  65. data/lib/samagotchi/web/session_control.rb +62 -3
  66. data/lib/samagotchi/web/session_show.rb +35 -5
  67. data/lib/samagotchi/worker.rb +57 -5
  68. data/lib/samagotchi/worker_idle_exit.rb +111 -9
  69. 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
- ...noMatch(r),
284
+ ...recordFlags(r),
284
285
  }));
285
286
  }
286
287
 
287
- function noMatch(record) {
288
- return record?.no_match ? { no_match: true } : {};
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, ...noMatch(r), ...call };
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
- return html`<div class="activity-row" data-key=${row.key}>${toolRowInnerTemplate(row, opts)}</div>`;
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. 200 the Bridge's {status: exiting,
313
- # session_id, discard}; 409 held with its {status, reason, tasks,
314
- # hint}; 503 not_live, 501 not_supported (an older worker), 504
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, delete: delete) }
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, stop, restart, archive, unarchive and
17
- # delete (handle_question_answer … handle_delete), with the refusals they
18
- # answer (owned_by_repl, owned_by_tui, archive_refused). Included in App:
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
@@ -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
- hold = @idle_exit.request_hold(requester: client_id, hint: TASK_STOP_HINT)
1434
- return hold if hold
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 +hint+ on
31
- # stopping them.
32
- Hold = Data.define(:reason, :tasks, :hint) do
33
- def initialize(reason:, tasks: nil, hint: nil) = super
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
- running.any? ? Hold.new(reason: :background_tasks, tasks: running, hint: hint) : nil
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.115.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