samagotchi 0.127.0 → 0.129.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 (61) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +106 -1
  3. data/docs/architecture.md +15 -5
  4. data/docs/cli.md +2 -1
  5. data/docs/guardrails.md +155 -25
  6. data/docs/internals/participants.md +23 -5
  7. data/docs/internals/web-push.md +4 -1
  8. data/docs/sessions.md +1 -1
  9. data/docs/testing.md +3 -2
  10. data/lib/samagotchi/bridge.rb +2 -0
  11. data/lib/samagotchi/bridge_client.rb +8 -2
  12. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  13. data/lib/samagotchi/engine.rb +56 -0
  14. data/lib/samagotchi/guardrail_wiring.rb +6 -5
  15. data/lib/samagotchi/guardrails/child_boundary.rb +23 -13
  16. data/lib/samagotchi/guardrails/outside.rb +54 -5
  17. data/lib/samagotchi/guardrails/pair_boundary.rb +78 -26
  18. data/lib/samagotchi/guardrails/pair_command.rb +261 -66
  19. data/lib/samagotchi/guardrails/parent_approvals.rb +2 -1
  20. data/lib/samagotchi/guardrails/protected_paths.rb +98 -5
  21. data/lib/samagotchi/guardrails/read_only_shell.rb +239 -9
  22. data/lib/samagotchi/guardrails/review_paths.rb +22 -8
  23. data/lib/samagotchi/guardrails/rm_targets.rb +4 -3
  24. data/lib/samagotchi/guardrails/scratch_writes.rb +3 -1
  25. data/lib/samagotchi/guardrails/shell_git_dirs.rb +67 -35
  26. data/lib/samagotchi/guardrails/shell_lex.rb +1 -1
  27. data/lib/samagotchi/guardrails/shell_paths.rb +73 -25
  28. data/lib/samagotchi/guardrails/targets.rb +128 -24
  29. data/lib/samagotchi/session_manager.rb +13 -14
  30. data/lib/samagotchi/system_one/self_protect.rb +108 -16
  31. data/lib/samagotchi/terminal_ui/attach_launcher.rb +9 -5
  32. data/lib/samagotchi/terminal_ui/attached_loop/queueing.rb +6 -12
  33. data/lib/samagotchi/terminal_ui/attached_loop/sending.rb +23 -35
  34. data/lib/samagotchi/terminal_ui/attached_loop.rb +10 -18
  35. data/lib/samagotchi/terminal_ui/exit_lines.rb +2 -1
  36. data/lib/samagotchi/terminal_ui/local_link.rb +2 -2
  37. data/lib/samagotchi/terminal_ui/own_turns.rb +81 -0
  38. data/lib/samagotchi/verify_anchor.rb +8 -0
  39. data/lib/samagotchi/version.rb +1 -1
  40. data/lib/samagotchi/web/app.rb +9 -7
  41. data/lib/samagotchi/web/participant_review.rb +4 -2
  42. data/lib/samagotchi/web/public/annotate_bar.js +14 -2
  43. data/lib/samagotchi/web/public/app.js +25 -5
  44. data/lib/samagotchi/web/public/composer.css +1 -0
  45. data/lib/samagotchi/web/public/notify.js +2 -1
  46. data/lib/samagotchi/web/public/participant.html +3 -2
  47. data/lib/samagotchi/web/public/participant.js +18 -7
  48. data/lib/samagotchi/web/public/participant_model.js +3 -2
  49. data/lib/samagotchi/web/public/participant_names.js +2 -2
  50. data/lib/samagotchi/web/public/question_card.js +4 -1
  51. data/lib/samagotchi/web/public/session_state.js +2 -0
  52. data/lib/samagotchi/web/public/stream_handlers.js +4 -2
  53. data/lib/samagotchi/web/public/verdict_card.js +18 -3
  54. data/lib/samagotchi/web/public/verdict_fresh.js +23 -5
  55. data/lib/samagotchi/web/public/verify_card.css +1 -0
  56. data/lib/samagotchi/web/public/verify_preset.js +6 -0
  57. data/lib/samagotchi/web/verify_fresh_routes.rb +12 -7
  58. data/lib/samagotchi/worker.rb +82 -32
  59. data/lib/samagotchi/worker_idle_exit.rb +17 -0
  60. data/lib/samagotchi/worker_link.rb +13 -5
  61. metadata +2 -1
@@ -53,6 +53,7 @@ body.has-session.composer-folded #composeGrip::after{opacity:1;width:48px;margin
53
53
  info bar's buttons while folded, the stage card's bottom in the stage view); it reaches 7 px below the edge, the info bar's
54
54
  border and padding, so it never covers a button of the bar. */
55
55
  @media(max-width:600px){
56
+ .composer-card-foot{padding-top:4px;padding-bottom:4px}
56
57
  #composeGrip{width:96px;height:26px;margin:-19px auto -7px}
57
58
  #composeGrip::after{top:17px;opacity:1}
58
59
  }
@@ -3,6 +3,7 @@
3
3
  // title badge for what it returns, while the tab is not in front.
4
4
 
5
5
  import { clip } from "./format.js";
6
+ import { isParticipantAuthor } from "./participant_model.js";
6
7
 
7
8
  // The bell's state. OS notifications need a secure context: on chi web
8
9
  // over http at a LAN address the API is missing or always denied, so the
@@ -164,7 +165,7 @@ export function attentionText(session, attention) {
164
165
  const seconds = Math.round(Number(session?.last_turn?.seconds) || 0);
165
166
  const verdict = session?.last_verdict;
166
167
  // An owner's verdict has no participant author (none, "" or "owner").
167
- const byParticipant = !!verdict?.by && verdict.by !== "owner";
168
+ const byParticipant = isParticipantAuthor(verdict?.by);
168
169
  const who = verdict?.by_name || (byParticipant ? "A participant" : "You");
169
170
  const verdictReason = clip(String(verdict?.reason || ""), VERDICT_REASON_MAX);
170
171
  const verdictIcon = { confirmed: "✅", refuted: "❌", unclear: "❓" }[verdict?.verdict] || "";
@@ -122,9 +122,10 @@ main{flex:1;min-height:0;overflow-y:auto;padding:16px 16px 8px}
122
122
  #composer textarea{font:inherit;line-height:1.45;padding:2px 0}
123
123
  #composer textarea:disabled{cursor:not-allowed}
124
124
  #composer .actions-col #send{height:30px;min-height:30px;line-height:30px;padding:0 16px;font-weight:600}
125
+ /* The key hint wraps to at most two lines (a narrow card cuts the second's end); the whole text is its title. */
126
+ .p-hint.composer-line{color:var(--text-faint);height:auto;max-height:36px;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;line-clamp:2}
125
127
  /* The participant's own usage (participant_model.js usageItems: the fields their grant's exposure shows), one line in
126
128
  the card's footer, the end cut. Folded, it stays under the grip. */
127
- .p-hint.composer-line{color:var(--text-faint);white-space:nowrap;text-overflow:ellipsis}
128
129
  .p-foot{min-width:0}
129
130
  .p-foot:has(> .p-usage[hidden]){display:none}
130
131
  /* Folded before the usage loads, the card keeps a footer's height under its grip, not a 2 px sliver. */
@@ -151,7 +152,7 @@ button.small{font-size:12px;padding:2px 8px}
151
152
  .p-people .p-person:nth-child(n+4){display:none}
152
153
  main{padding:12px 16px 6px}
153
154
  .p-bubble{padding:9px 12px}
154
- .p-hint,.p-note{display:none}
155
+ .p-hint.composer-line,.p-note{display:none}
155
156
  }
156
157
  /* Under 16px iOS Safari zooms the page on focus (and a Home Screen app never zooms back). */
157
158
  @media (max-width:600px),(pointer:coarse){#composer textarea{font-size:16px}}
@@ -12,7 +12,7 @@
12
12
  // (spend vs cap, queue, link expiry, model). The owner's participant view
13
13
  // mounts none.
14
14
  import {
15
- authorColor, participantAuthor, authorLabel, toolLine, refusalText, dequeueText, endedText, streamURL, reloads, endsLiveTurn, liveFrom, applyLive,
15
+ authorColor, participantAuthor, isParticipantAuthor, authorLabel, toolLine, refusalText, dequeueText, endedText, streamURL, reloads, endsLiveTurn, liveFrom, applyLive,
16
16
  liveText, withToolSteps, pickSubject, subjectBody, subjectsKey, narrowView, retryDelay, retries, threadGaps, usageItems,
17
17
  modeCopy,
18
18
  } from "./participant_model.js";
@@ -22,10 +22,11 @@ import { createAuthLost, lostAnswer } from "./auth_lost.js";
22
22
  import { REPLACE_SESSION_TEXT, fragmentCode, tradeFragmentCode } from "./token_fragment.js";
23
23
  import { createAnnotateBar } from "./annotate_bar.js";
24
24
  import { parsePresets } from "./annotate_presets.js";
25
- import { VERIFY_PRESET, withVerify } from "./verify_preset.js";
25
+ import { VERIFY_PRESET, VERIFY_UNAVAILABLE, withVerify } from "./verify_preset.js";
26
26
  import { appendQuote } from "./annotations.js";
27
27
  import { messageAuthorData, participantQuote, participantSource } from "./participant_annotate.js";
28
28
  import { claimFromHash, selectionClaim, showClaim } from "./claim_link.js";
29
+ import { CHECK_NOT_ON_PAGE } from "./verdict_card.js";
29
30
  import { freshURL, placeVerdictWithFresh, settleRunning } from "./verdict_fresh.js";
30
31
  import { mountComposer } from "./composer_mount.js";
31
32
  import { PROMPT_LINE_PX } from "./composer_size.js";
@@ -234,7 +235,7 @@ function render() {
234
235
  const copy = modeCopy(viewer.mode);
235
236
  $("note").textContent = ownerView ? "" : copy.note;
236
237
  $("hint").textContent = `⏎ sends, ⇧⏎ a new line, ↑ an earlier comment. ${copy.hint}`;
237
- $("hint").title = $("hint").textContent; // one line in the card: the whole text on hover
238
+ $("hint").title = $("hint").textContent; // at most two lines in the card: the whole text on hover
238
239
  $("head").hidden = false;
239
240
  $("main").hidden = false;
240
241
  renderPlan();
@@ -343,7 +344,7 @@ function messageNode(message) {
343
344
  const who = el("div", "p-who");
344
345
  who.append(el("b", "", authorLabel(message)));
345
346
  if (message.merged) who.append(el("span", "p-tag", "added mid-turn"));
346
- if (message.author) who.append(el("span", "p-tag", modeCopy(message.participant_mode).tag));
347
+ if (isParticipantAuthor(message.author)) who.append(el("span", "p-tag", modeCopy(message.participant_mode).tag));
347
348
  node.append(who);
348
349
  const text = message.content + (message.images ? `\n[${message.images} image${message.images > 1 ? "s" : ""}]` : "");
349
350
  node.append(el("div", "p-bubble", text));
@@ -413,13 +414,17 @@ function renderThread() {
413
414
  const author = record.by || null;
414
415
  const viewer = data.viewer || {};
415
416
  let who = ownerView ? "You" : "Owner";
416
- if (author && author !== "owner") {
417
+ if (isParticipantAuthor(author)) {
417
418
  if (!ownerView && author === participantAuthor(viewer.id)) who = "You";
418
419
  else {
419
420
  who = (data.participants || []).find((participant) => participantAuthor(participant.id) === author)?.name || "A participant";
420
421
  }
421
422
  }
422
- placeVerdictWithFresh(thread, record, { who, author }, verifyFreshState());
423
+ placeVerdictWithFresh(thread, record, {
424
+ who,
425
+ author,
426
+ onMissingCheck: () => showClaimNote(CHECK_NOT_ON_PAGE),
427
+ }, verifyFreshState());
423
428
  }
424
429
  if (atBottom || !thread.dataset.drawn) main.scrollTop = main.scrollHeight;
425
430
  thread.dataset.drawn = "1";
@@ -429,12 +434,16 @@ function renderThread() {
429
434
  // participant starts one while their grant could run a turn now
430
435
  // (can_comment); the owner looking through the participant view never
431
436
  // does, and no one here gets a link to the child's session.
437
+ const freshErrors = {};
432
438
  function verifyFreshState() {
433
439
  if (!Array.isArray(data.fresh_running)) data.fresh_running = [];
440
+ const unavailable = data.session?.verify_unavailable === true;
434
441
  return {
435
442
  verdicts: data.verdicts || [],
436
443
  running: data.fresh_running,
437
- canStart: !ownerView && !ended && !!data.viewer?.can_comment,
444
+ errors: freshErrors,
445
+ canStart: !unavailable && !ownerView && !ended && !!data.viewer?.can_comment,
446
+ unavailableReason: unavailable ? VERIFY_UNAVAILABLE : null,
438
447
  start: async (of) => {
439
448
  const { ok, body } = await api(freshURL(sessionId, of), {
440
449
  method: "POST", body: "{}", headers: { "Content-Type": "application/json" },
@@ -650,6 +659,8 @@ for (const button of $("viewSeg").querySelectorAll("button")) {
650
659
  // while this participant can't comment.
651
660
  const annotateBar = createAnnotateBar({
652
661
  presets: withVerify(parsePresets(body.annotatePresets)),
662
+ disabledPreset: (preset) => preset === VERIFY_PRESET
663
+ && data?.session?.verify_unavailable === true ? VERIFY_UNAVAILABLE : null,
653
664
  canAnnotate: () => !ownerView && !ended && !!data?.viewer?.can_comment,
654
665
  roots: () => [$("planDoc"), $("thread")],
655
666
  isLive: (root) => !!root.closest?.(".p-msg.live"),
@@ -9,6 +9,7 @@ import { fullTime, gapText } from "./format.js";
9
9
  // (Steer.author_for / PARTICIPANT_PREFIX): the one place the web spells it.
10
10
  export const PARTICIPANT_PREFIX = "p:";
11
11
  export const participantAuthor = (id) => `${PARTICIPANT_PREFIX}${id}`;
12
+ export const isParticipantAuthor = (by) => typeof by === "string" && by.startsWith(PARTICIPANT_PREFIX);
12
13
 
13
14
  // Six author colours (tokens.css --author-1..6), one per participant id,
14
15
  // the same on every page that shows them (the owner's badges too).
@@ -22,13 +23,13 @@ export function authorSlot(author) {
22
23
 
23
24
  // The CSS colour of an author: a participant's slot, else the owner's.
24
25
  export function authorColor(author) {
25
- return author ? `var(--author-${authorSlot(author)})` : "var(--author-owner)";
26
+ return isParticipantAuthor(author) ? `var(--author-${authorSlot(author)})` : "var(--author-owner)";
26
27
  }
27
28
 
28
29
  // Who wrote a user message or started a turn, as the page names them.
29
30
  export function authorLabel(item) {
30
31
  if (item?.mine) return "You";
31
- if (item?.author) return item.author_name || "A participant";
32
+ if (isParticipantAuthor(item?.author)) return item.author_name || "A participant";
32
33
  if (item?.automatic) return "Automatic";
33
34
  return "Owner";
34
35
  }
@@ -4,7 +4,7 @@
4
4
  // prompt's own author_name (a saved message, turn_started) comes first; a
5
5
  // badge drawn before the list came, with neither, says UNNAMED until
6
6
  // nameUnnamedBadges names it.
7
- import { authorColor, participantAuthor } from "./participant_model.js";
7
+ import { authorColor, isParticipantAuthor, participantAuthor } from "./participant_model.js";
8
8
 
9
9
  export const UNNAMED = "a participant";
10
10
 
@@ -28,7 +28,7 @@ export function participantName(author) {
28
28
  // +mode+: the turn's participant_mode stamp ("review", "pair"; the
29
29
  // message's, or turn_started's), kept as mode: when known.
30
30
  export function authorBadge(author, name = null, mode = null) {
31
- if (!author || author === "owner") return null;
31
+ if (!isParticipantAuthor(author)) return null;
32
32
  const known = name || participantName(author);
33
33
  return { author, name: known || UNNAMED, color: authorColor(author), ...(known ? {} : { unnamed: true }), ...(mode ? { mode } : {}) };
34
34
  }
@@ -3,6 +3,7 @@
3
3
  // command or paths, where and why. Its options end in "Deny"; the answer's
4
4
  // last option denies. A page that doesn't know approvals still shows the
5
5
  // plain question text, which says the same.
6
+ import { isParticipantAuthor, PARTICIPANT_PREFIX } from "./participant_model.js";
6
7
 
7
8
  export function isApproval(pq) {
8
9
  return !!pq && pq.kind === "approval";
@@ -68,7 +69,9 @@ export function relayView(pq) {
68
69
  export function approvalAsker(pq) {
69
70
  const a = (pq && pq.approval) || {};
70
71
  if (!a.author) return null;
71
- return a.author_name ? String(a.author_name) : String(a.author).replace(/^p:/, "");
72
+ const author = String(a.author);
73
+ return a.author_name ? String(a.author_name)
74
+ : isParticipantAuthor(author) ? author.slice(PARTICIPANT_PREFIX.length) : author;
72
75
  }
73
76
 
74
77
  // @return {null | {tool, what, isCommand, where, why, paths?, preview?, delegate?, asker?}};
@@ -53,6 +53,7 @@ export function emptySessionState() {
53
53
  // worker_version / worker_features): the info bar's stale-worker badge.
54
54
  workerVersion: null,
55
55
  workerFeatures: [],
56
+ bridgeUp: false,
56
57
  // The LLM context strategy, apply rule and budget the next turn runs
57
58
  // under, each with its source (the info bar's llm ctx chip), or null.
58
59
  llmContext: null,
@@ -95,6 +96,7 @@ export function sessionStateFrom(data) {
95
96
  // not at the session's next hub event (followSelectedSession).
96
97
  workerVersion: s.worker_version || null,
97
98
  workerFeatures: Array.isArray(s.worker_features) ? s.worker_features : [],
99
+ bridgeUp: s.bridge_up === true,
98
100
  timing: normalizeTiming(data.timing),
99
101
  };
100
102
  // The saved fill, so a reloaded or stopped session shows it before its
@@ -55,6 +55,7 @@ import {
55
55
  import { commandView, drawCommandBubble, historyHasCommandBubble } from "./commands.js";
56
56
  import { keepEarlyRestore, restoreAction } from "./restore.js";
57
57
  import { PAIR, setBadgeMode } from "./participant_names.js";
58
+ import { isParticipantAuthor } from "./participant_model.js";
58
59
 
59
60
  export function createStreamHandlers(deps) {
60
61
  const {
@@ -163,12 +164,13 @@ export function createStreamHandlers(deps) {
163
164
  // A participant's review turn: its id and author, for Apply at its end
164
165
  // (turn_completed names neither). A pair turn acted itself: no Apply.
165
166
  const author = data.author || data.origin?.author;
167
+ const participant = isParticipantAuthor(author);
166
168
  const pair = data.participant_mode === PAIR;
167
- liveTurn.review = author && author !== "owner" && data.turn_id && !pair ? { turn_id: data.turn_id, author, author_name: data.author_name } : null;
169
+ liveTurn.review = participant && data.turn_id && !pair ? { turn_id: data.turn_id, author, author_name: data.author_name } : null;
168
170
  // Its prompt drawn while queued takes the turn's mode now.
169
171
  if (data.participant_mode) setBadgeMode(promptBubble, data.participant_mode);
170
172
  // The owner's input queues behind it (the composer's delivery line).
171
- liveTurn.participantTurn = !!(author && author !== "owner");
173
+ liveTurn.participantTurn = participant;
172
174
  setTurnRunning(true);
173
175
  setLiveStatus("running");
174
176
  liveTurn.timing.detach();
@@ -1,10 +1,23 @@
1
+ import { ownerClaimHash } from "./claim_link.js";
2
+
1
3
  const TYPES = Object.freeze({
2
4
  confirmed: { icon: "✅", label: "confirmed" },
3
5
  refuted: { icon: "❌", label: "refuted" },
4
6
  unclear: { icon: "❓", label: "unclear" },
5
7
  });
6
8
 
7
- export function placeVerdict(root, record, { who = "Owner", author = null } = {}) {
9
+ export const CHECK_NOT_ON_PAGE = "That check is no longer on this page.";
10
+
11
+ export function ownerCheckHash(sessions, currentSessionId, checkTurnId) {
12
+ if (!Array.isArray(sessions) || typeof checkTurnId !== "string") return null;
13
+ const session = sessions.find((candidate) =>
14
+ candidate?.id !== currentSessionId
15
+ && Array.isArray(candidate.verdicts)
16
+ && candidate.verdicts.some((verdict) => verdict?.check_turn_id === checkTurnId));
17
+ return session ? ownerClaimHash(session.id, { turnId: checkTurnId }) : null;
18
+ }
19
+
20
+ export function placeVerdict(root, record, { who = "Owner", author = null, onMissingCheck = null } = {}) {
8
21
  const anchor = record?.anchor;
9
22
  const type = TYPES[record?.verdict];
10
23
  if (!root || !type || typeof anchor?.turn_id !== "string" || typeof anchor?.quote !== "string"
@@ -47,8 +60,10 @@ export function placeVerdict(root, record, { who = "Owner", author = null } = {}
47
60
  link.textContent = "View check";
48
61
  link.addEventListener("click", (event) => {
49
62
  event.preventDefault();
50
- [...root.querySelectorAll("[data-turn-id]")].find((element) => element.dataset.turnId === record.check_turn_id)
51
- ?.scrollIntoView?.({ behavior: "smooth", block: "center" });
63
+ const check = [...root.querySelectorAll("[data-turn-id]")]
64
+ .find((element) => element.dataset.turnId === record.check_turn_id);
65
+ if (check) check.scrollIntoView?.({ behavior: "smooth", block: "center" });
66
+ else onMissingCheck?.(record);
52
67
  });
53
68
  card.append(heading, quote, reason, link);
54
69
 
@@ -29,7 +29,8 @@ export function settleRunning(running, record) {
29
29
  // Places a session verdict as the pages draw them: an original verdict's
30
30
  // card (verdict_card.js) with its Verify fresh part, or a fresh verdict on
31
31
  // its original's card. +fresh+: {verdicts (all of the session's), running
32
- // (the page's own array; a start adds to it), canStart, start(of) → Promise
32
+ // (the page's own array; a start adds to it), errors (the page's own
33
+ // object, check id → the last start error, so a redraw shows it again), canStart, start(of) → Promise
33
34
  // of {ok, child_id, error}, childHref(id) → a link to the child or null}.
34
35
  export function placeVerdictWithFresh(root, record, opts = {}, fresh = {}) {
35
36
  if (record?.fresh) {
@@ -43,6 +44,11 @@ export function placeVerdictWithFresh(root, record, opts = {}, fresh = {}) {
43
44
  return card;
44
45
  }
45
46
 
47
+ // Every verdict card under +root+ drawn again from +fresh+ (the gate changed).
48
+ export function redrawFresh(root, fresh = {}) {
49
+ for (const card of root?.querySelectorAll?.(".verify-verdict") || []) renderFresh(card, fresh);
50
+ }
51
+
46
52
  // The card's Verify fresh part, drawn again from +fresh+: the fresh
47
53
  // verdicts, the running check, else the button (an unclear verdict, and a
48
54
  // viewer who could run a turn now).
@@ -79,6 +85,17 @@ export function renderFresh(card, fresh = {}) {
79
85
  const link = childLink(typeof run === "string" ? null : run.child_id);
80
86
  if (link) line.append(" ", link);
81
87
  part.append(line);
88
+ } else if (card.classList.contains("unclear") && fresh.unavailableReason) {
89
+ const button = doc.createElement("button");
90
+ button.type = "button";
91
+ button.className = "verify-fresh-btn";
92
+ button.textContent = "Verify fresh";
93
+ button.title = fresh.unavailableReason;
94
+ button.disabled = true;
95
+ const note = doc.createElement("p");
96
+ note.className = "verify-fresh-unavailable";
97
+ note.textContent = fresh.unavailableReason;
98
+ part.append(button, note);
82
99
  } else if (card.classList.contains("unclear") && fresh.canStart && fresh.start) {
83
100
  const button = doc.createElement("button");
84
101
  button.type = "button";
@@ -89,10 +106,11 @@ export function renderFresh(card, fresh = {}) {
89
106
  part.append(button);
90
107
  }
91
108
 
92
- if (card.dataset.freshError) {
109
+ const error = (fresh.errors ||= {})[of];
110
+ if (error) {
93
111
  const line = doc.createElement("p");
94
112
  line.className = "verify-fresh-error";
95
- line.textContent = card.dataset.freshError;
113
+ line.textContent = error;
96
114
  part.append(line);
97
115
  }
98
116
  if (part.childNodes.length) card.append(part);
@@ -109,12 +127,12 @@ async function startFresh(card, button, fresh) {
109
127
  result = { ok: false, error: error?.message };
110
128
  }
111
129
  if (result?.ok) {
112
- delete card.dataset.freshError;
130
+ if (fresh.errors) delete fresh.errors[of];
113
131
  if (fresh.running && !fresh.running.some((entry) => runningOf(entry) === of)) {
114
132
  fresh.running.push(result.child_id ? { of, child_id: result.child_id } : of);
115
133
  }
116
134
  } else {
117
- card.dataset.freshError = result?.error || "Couldn't start a fresh check.";
135
+ (fresh.errors ||= {})[of] = result?.error || "Couldn't start a fresh check.";
118
136
  }
119
137
  renderFresh(card, fresh);
120
138
  }
@@ -11,6 +11,7 @@
11
11
  .verify-fresh-result{font-weight:600;color:var(--text-bright)}
12
12
  .verify-fresh-running{color:var(--text-muted);font-style:italic}
13
13
  .verify-fresh-error{color:var(--danger-text)}
14
+ .verify-fresh-unavailable{color:var(--text-muted)}
14
15
  .verify-fresh-link{color:var(--accent);font-weight:400}
15
16
  .verify-fresh-btn{font:inherit;font-size:12px;font-weight:600;padding:3px 12px;border-radius:8px;cursor:pointer;background:var(--accent-bg);color:var(--accent);border:1px solid var(--accent-border)}
16
17
  .verify-fresh-btn:hover{border-color:var(--accent)}
@@ -12,6 +12,12 @@ export const VERIFY_PRESET = Object.freeze({
12
12
  + "End with a line `Verdict: confirmed|refuted|unclear — <one-line reason>`.",
13
13
  });
14
14
 
15
+ export const VERIFY_UNAVAILABLE = "restart this session to use Verify";
16
+
17
+ export function verifyWorkerUnavailable(workerFeatures, bridgeUp) {
18
+ return bridgeUp === true && Array.isArray(workerFeatures) && !workerFeatures.includes("verify");
19
+ }
20
+
15
21
  // +presets+ (annotate_presets.js parsePresets) with Verify after them.
16
22
  export function withVerify(presets) {
17
23
  return [...(presets || []), VERIFY_PRESET];
@@ -33,14 +33,18 @@ module Samagotchi
33
33
  def handle_verify_fresh(req, id, of)
34
34
  clicker = principal(req).owner? ? nil : principal(req).grant
35
35
  session = @session_class.load(id, state_dir: default_state_dir)
36
- if clicker && (refused = participant_cap_refusal(clicker) || fresh_worker_refusal(session))
36
+ if clicker && (refused = participant_cap_refusal(clicker) || fresh_worker_refusal(session, participant: true))
37
37
  return verify_fresh_refused(clicker, of, *refused)
38
38
  end
39
39
 
40
40
  by = Steer.sender_author(field_of(VerifyFresh.original(session.verdicts, of), :by))
41
41
  runner = by ? verdict_author_grant(id, by) : clicker
42
42
  return author_link_ended(clicker, id, of, by) if by && runner.nil?
43
- if runner && runner != clicker && (refused = participant_cap_refusal(runner) || fresh_worker_refusal(session))
43
+ if runner && runner != clicker && (refused = participant_cap_refusal(runner) ||
44
+ fresh_worker_refusal(session, participant: true))
45
+ return verify_fresh_refused(clicker, of, *refused, runner: runner)
46
+ end
47
+ if (refused = fresh_worker_refusal(session))
44
48
  return verify_fresh_refused(clicker, of, *refused, runner: runner)
45
49
  end
46
50
 
@@ -76,12 +80,13 @@ module Samagotchi
76
80
 
77
81
  def field_of(record, key) = record && (record[key] || record[key.to_s])
78
82
 
79
- # A live worker of the session that predates participants' turns runs
80
- # no participant's turn (Delivery::REVIEW_UNAVAILABLE): no fresh
81
- # child for one either. nil to go on.
82
- def fresh_worker_refusal(session)
83
+ # A live worker must support VerifyFresh's parent checks. For a
84
+ # participant's child it must also have the review boundary.
85
+ def fresh_worker_refusal(session, participant: false)
83
86
  features = ParticipantMode.live_features(session, default_state_dir)
84
- return nil if features.nil? || features.include?(Delivery::REVIEW_FEATURE)
87
+ return nil if features.nil?
88
+ return [409, VerifyAnchor::UNAVAILABLE["error"], VerifyAnchor::UNAVAILABLE["detail"]] if VerifyAnchor.worker_unavailable?(features)
89
+ return nil unless participant && !features.include?(Delivery::REVIEW_FEATURE)
85
90
 
86
91
  [409, Delivery::REVIEW_UNAVAILABLE["error"], Delivery::REVIEW_UNAVAILABLE["detail"]]
87
92
  end
@@ -168,6 +168,10 @@ module Samagotchi
168
168
  @loop_started = false
169
169
  @left_loop = false
170
170
  @signal_mutex = Mutex.new
171
+ # The exit being taken or taken (:exit, :delete, :restart), claimed
172
+ # under its own lock before #exit_request's slow stop-all (#claim_exit).
173
+ @exit_claim = nil
174
+ @exit_claim_mutex = Mutex.new
171
175
  @grace_expired = false
172
176
  # The running turn has ended (its end event) and its after_turn hooks
173
177
  # run: a signal's grace waits for them (#signalled).
@@ -1583,41 +1587,89 @@ module Samagotchi
1583
1587
  def exit_request(client_id, delete: false, restart: false, stop: false, ask: true)
1584
1588
  # The Bridge serves before the idle-exit policy exists.
1585
1589
  return WorkerIdleExit::Hold.new(reason: :starting) unless @idle_exit
1586
- # One exit is already taken: the first one decides how the worker
1587
- # leaves (a later stop-all can't turn a restart, whose reminders go to
1588
- # the next worker, into a plain exit). Held at the leave, it is
1589
- # forgotten and the next request counts again.
1590
- return nil if @exit_requested
1591
1590
 
1592
- if restart
1593
- hold = @idle_exit.hold_for_restart
1594
- return WorkerIdleExit::Hold.new(reason: hold) if hold
1595
- else
1596
- # /exit names the tasks it waits for (D3), from the one look that found them.
1597
- # The ask covers the delegate children too, but only for a plain /exit: an
1598
- # exit that deletes the session, or one a UI won't ask about, ignores them.
1599
- hold = @idle_exit.request_hold(requester: client_id, hint: TASK_STOP_HINT, ask: ask && !delete)
1600
- if hold
1601
- # A hold the user can't stop here: no ask, today's refusal.
1602
- return hold unless hold.ask?
1603
- # The ask's 409: the list, for the terminal to ask with.
1604
- return hold unless stop
1605
-
1606
- @stopped_holds = @hold_stop.stop_all
1607
- # An unstoppable hold may have arrived while stopping (a child stopped
1608
- # here is no longer counted).
1609
- recheck = @idle_exit.request_hold(requester: client_id, hint: TASK_STOP_HINT)
1610
- return recheck if recheck
1611
- end
1591
+ kind = if restart then :restart
1592
+ elsif delete then :delete
1593
+ else :exit
1594
+ end
1595
+ # One exit at a time, and the first one taken decides how the worker
1596
+ # leaves (a later stop-all can't turn a restart, whose reminders go to
1597
+ # the next worker, into a plain exit): claimed before the slow
1598
+ # stop-all, so a request of another kind meanwhile is refused (409),
1599
+ # not told it went through. Held at the leave, it is forgotten and the
1600
+ # next request counts again.
1601
+ refused = claim_exit(kind)
1602
+ return refused.value if refused
1603
+
1604
+ hold = exit_hold(client_id, kind: kind, stop: stop, ask: ask)
1605
+ if hold
1606
+ forget_exit
1607
+ return hold
1612
1608
  end
1613
1609
 
1614
1610
  @stopped_holds = nil unless stop
1615
- @exit_requested = true
1616
1611
  @exit_requested_by = client_id
1617
- @exit_deletes = delete && !restart
1618
- @exit_restart = restart
1612
+ @exit_deletes = kind == :delete
1613
+ @exit_restart = kind == :restart
1614
+ @exit_requested = true
1619
1615
  @waker.wake
1620
1616
  nil
1617
+ rescue StandardError
1618
+ # A look or a stop that raised lets the claim go (the Bridge answers 500).
1619
+ forget_exit unless @exit_requested
1620
+ raise
1621
+ end
1622
+
1623
+ # One answer for an exit request that doesn't get the claim, boxed so a
1624
+ # nil (accepted) is told apart from "claimed".
1625
+ ExitRefused = Data.define(:value)
1626
+
1627
+ # @return [ExitRefused, nil] nil when this request now holds the claim;
1628
+ # else its answer: nil (accepted: an exit of the same kind is already
1629
+ # taken) or WorkerIdleExit.leaving (one of another kind is, or is still
1630
+ # being taken)
1631
+ def claim_exit(kind)
1632
+ @exit_claim_mutex.synchronize do
1633
+ next ExitRefused.new(value: nil) if @exit_claim == kind && @exit_requested
1634
+ next ExitRefused.new(value: WorkerIdleExit.leaving(@exit_claim)) if @exit_claim
1635
+
1636
+ @exit_claim = kind
1637
+ nil
1638
+ end
1639
+ end
1640
+
1641
+ # The taken exit is let go: held at the leave, or its hand-off failed.
1642
+ def forget_exit
1643
+ @exit_claim_mutex.synchronize do
1644
+ @exit_requested = nil
1645
+ @exit_restart = false
1646
+ @exit_claim = nil
1647
+ end
1648
+ end
1649
+
1650
+ # What keeps the worker up for this request, nil when nothing does; a
1651
+ # stop request stops what the user can stop first (#exit_request).
1652
+ # @return [WorkerIdleExit::Hold, nil]
1653
+ def exit_hold(client_id, kind:, stop:, ask:)
1654
+ if kind == :restart
1655
+ hold = @idle_exit.hold_for_restart
1656
+ return hold && WorkerIdleExit::Hold.new(reason: hold)
1657
+ end
1658
+
1659
+ # /exit names the tasks it waits for (D3), from the one look that found them.
1660
+ # The ask covers the delegate children too, but only for a plain /exit: an
1661
+ # exit that deletes the session, or one a UI won't ask about, ignores them.
1662
+ hold = @idle_exit.request_hold(requester: client_id, hint: TASK_STOP_HINT, ask: ask && kind != :delete)
1663
+ return nil unless hold
1664
+ # A hold the user can't stop here: no ask, today's refusal.
1665
+ return hold unless hold.ask?
1666
+ # The ask's 409: the list, for the terminal to ask with.
1667
+ return hold unless stop
1668
+
1669
+ @stopped_holds = @hold_stop.stop_all
1670
+ # An unstoppable hold may have arrived while stopping (a child stopped
1671
+ # here is no longer counted).
1672
+ @idle_exit.request_hold(requester: client_id, hint: TASK_STOP_HINT)
1621
1673
  end
1622
1674
 
1623
1675
  # #leave_idle for an exit a client asked for: check again with the event
@@ -1636,8 +1688,7 @@ module Samagotchi
1636
1688
  @idle_exit.hold_for_request(requester: @exit_requested_by, streams: false)
1637
1689
  end
1638
1690
  if hold
1639
- @exit_requested = nil
1640
- @exit_restart = false
1691
+ forget_exit
1641
1692
  Log.info(:worker, "exit_held", reason: hold)
1642
1693
  next false
1643
1694
  end
@@ -1666,8 +1717,7 @@ module Samagotchi
1666
1717
  end
1667
1718
  true
1668
1719
  rescue StandardError => e
1669
- @exit_requested = nil
1670
- @exit_restart = false
1720
+ forget_exit
1671
1721
  Log.warn(:worker, "reminders_carry_failed", error: e.class.name, msg: e.message)
1672
1722
  false
1673
1723
  end
@@ -39,6 +39,23 @@ module Samagotchi
39
39
  def ask? = !holds.empty?
40
40
  end
41
41
 
42
+ # The 409 reason for an exit request when another client's exit, of
43
+ # another kind, is already taken (Worker#exit_request): the first one
44
+ # decides how the worker leaves, so a restart asked after a plain /exit
45
+ # is refused rather than told "restarting".
46
+ LEAVING = { exit: :leaving_exit, delete: :leaving_delete, restart: :leaving_restart }.freeze
47
+ # What each of those says, for every client's refusal line
48
+ # (TerminalUI::ExitLines, SessionManager::RestartRefused).
49
+ LEAVING_WORDS = {
50
+ leaving_exit: "the worker is already leaving as a plain exit",
51
+ leaving_delete: "the worker is already leaving as an exit that deletes the session",
52
+ leaving_restart: "the worker is already leaving as a restart"
53
+ }.freeze
54
+
55
+ # @param kind [Symbol] the exit already taken (:exit, :delete or :restart)
56
+ # @return [Hold] the refusal for a request of another kind
57
+ def self.leaving(kind) = Hold.new(reason: LEAVING.fetch(kind))
58
+
42
59
  # One kind of hold the exit ask offers to stop: +kind+ (:reminders,
43
60
  # :background_tasks or :delegates) and its +items+ (#HoldItem). The 409's
44
61
  # +holds+ is this in JSON: `{kind:, items: [{id:, label:, detail:}]}`.
@@ -121,7 +121,7 @@ module Samagotchi
121
121
  # @param owner [OwnerLock::Owner, nil, :read] the lock's holder as the
122
122
  # caller already read it (nil: free); :read reads it here
123
123
  # @return [Live, Starting, Leaving, None, Stale, Skew]
124
- def resolve(session_dir, unlink:, owner: :read, host: BridgeClient::HOST)
124
+ def resolve(session_dir, unlink:, owner: :read)
125
125
  owner = OwnerLock.owner(session_dir) if owner == :read
126
126
  stamp = file_stamp(session_dir)
127
127
  sidecar = WorkerSidecar.read(session_dir)
@@ -136,7 +136,7 @@ module Samagotchi
136
136
 
137
137
  # @return [BridgeClient, nil] a client for the session's Live worker
138
138
  def client(session_id, session_dir:, unlink: true, host: BridgeClient::HOST, read_timeout: BridgeClient::READ_TIMEOUT)
139
- link = resolve(session_dir, unlink: unlink, host: host)
139
+ link = resolve(session_dir, unlink: unlink)
140
140
  link.live? ? link.client(session_id, host: host, read_timeout: read_timeout) : nil
141
141
  end
142
142
 
@@ -152,11 +152,11 @@ module Samagotchi
152
152
  # @return [Live, Starting, Leaving, None, Stale, Skew] the outcome it
153
153
  # stopped at, else the last one seen
154
154
  def await(_session_id, session_dir:, timeout:, unlink: true, interval: BridgeClient::SPAWN_POLL_INTERVAL,
155
- until: nil, give_up: nil, host: BridgeClient::HOST)
155
+ until: nil, give_up: nil)
156
156
  wanted = binding.local_variable_get(:until) # a keyword: no plain read
157
157
  last = nil
158
158
  found = BridgeClient.poll(timeout, interval: interval) do
159
- last = resolve(session_dir, unlink: unlink, host: host)
159
+ last = resolve(session_dir, unlink: unlink)
160
160
  last if (last.live? && (wanted.nil? || wanted.call(last))) || give_up&.call(last)
161
161
  end
162
162
  found || last
@@ -189,7 +189,15 @@ module Samagotchi
189
189
  def unowned(session_dir, sidecar, owner, stamp, unlink)
190
190
  return None.new(owner: owner) unless sidecar
191
191
 
192
- unlink_unchanged(session_dir, stamp) if unlink
192
+ if unlink
193
+ # A worker that took the lock and wrote its sidecar since the lock
194
+ # was read (before the stamp): its fresh file is the one stamped, so
195
+ # read the lock again just before the unlink, and take that worker.
196
+ fresh = OwnerLock.owner(session_dir)
197
+ return resolve(session_dir, unlink: false, owner: fresh) if fresh&.worker?
198
+
199
+ unlink_unchanged(session_dir, stamp)
200
+ end
193
201
  Stale.new(sidecar: sidecar, reason: :lock_free, owner: owner)
194
202
  end
195
203
  private_class_method :unowned