Skip to content

fix(run-decisionboard): arm poll before selftest answers - #144

Merged
Freudator86 merged 4 commits into
mainfrom
fm/fm-decisionboard-answer-path-broken
Aug 19, 2026
Merged

fix(run-decisionboard): arm poll before selftest answers#144
Freudator86 merged 4 commits into
mainfrom
fm/fm-decisionboard-answer-path-broken

Conversation

@Freudator86

Copy link
Copy Markdown
Collaborator

Intent

Fix the run-decisionboard selftest so it does not manufacture a false board defect: the selftest must arm the Lavish poll before answering and must assert the listener precondition before trusting the answer path. Keep the board implementation unchanged. Fix screenshot ergonomics so repeated selftest runs do not fail on stale screenshot targets, while preserving the correct refusal to treat an unchanged or missing screenshot as evidence. Pin the false-negative behavior with tests, preserve the one-click board-check practice, and record that sandboxed workers must set TMPDIR and XDG_RUNTIME_DIR inside their own worktree because an unreadable temp board makes the Lavish open step return 500. The branch has already proved the live path with two clean back-to-back selftest runs using worktree-local scratch dirs.

What Changed

  • Updated run-decisionboard selftest to arm Lavish polling before querying or answering, wait for poll listening: yes, and refuse to proceed when the listener precondition is not met.
  • Changed screenshot staging to use unique default targets with an optional FM_RUN_DECISIONBOARD_SHOT_ID, while still rejecting missing or unchanged screenshots as invalid evidence.
  • Expanded driver tests for poll ordering, missing-listener refusal, unique/stale screenshot behavior, and documented worktree-local TMPDIR/XDG_RUNTIME_DIR setup for sandboxed workers.

Risk Assessment

✅ Low: The change is tightly scoped to the run-decisionboard driver and behavior tests, and the reviewed paths now preserve the stated poll-listener and screenshot freshness invariants without changing the board implementation.

Testing

Inspected the run-decisionboard diff, ran the focused shell behavior tests, retried the live selftest after fixing the local setup by setting FM_BOARD_VESSEL, and captured screenshot/HTML/log evidence showing the poll was armed before answering and the answer returned through Lavish.

  • Evidence: Live run-decisionboard selftest screenshot (local file: /tmp/no-mistakes-evidence/01M0BKKC571XXK181ZGY7XZETA/run-decisionboard-selftest.png)
Evidence: Rendered fixture board from live selftest
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Probebrett</title>
<style>
/* layout.css - the single owner of Firstmate board styling.
 *
 * bin/fm-board.sh inlines this file into every board it builds, so a board
 * carries its own styling and makes ZERO network requests. Nothing here may
 * reference a remote font, image, or stylesheet: @import and url(http...) are
 * refused by the builder, which is what keeps the no-network guarantee true
 * rather than merely intended.
 *
 * THE LANGUAGE
 * This is Tally, this vessel's own design language, adopted 2026-08-17. Its two
 * laws are what every rule below answers to:
 *
 *   Ink is the record. Colour is a claim on the captain, and nothing else.
 *   Every sheet declares its own incompleteness, in the same instrument that
 *   carries its counts.
 *
 * A board is not a room. It is a SHEET: struck by a machine at a named moment,
 * handed to the captain, marked by hand, and read back by a machine. The
 * register bands, the square that is the unit of account, the chamfered
 * orientation corner, the live count of blanks, and the reservation block all
 * follow from that one fact rather than from a mood.
 *
 * Three hues exist in the whole system - open, gate, held - plus one veil for
 * what the sheet is withholding. Under way, landed, and failed get NO hue: they
 * are ink, differentiated by glyph and by strike. That scarcity is the point.
 * On a busy sheet red is rare and therefore still means something.
 *
 * Colour is never the only cue. Every state carries a distinct glyph, a word,
 * and only then a colour. `--veil-fold` is never a state of an item; it marks
 * what the sheet does not show.
 *
 * Every colour value appears in exactly two places: the light block below and
 * the dark media block. No component rule anywhere names a raw colour, except
 * the @media print ground, because paper is white whatever the reader's theme
 * is. A built board follows the reader's device and carries no theme toggle, so
 * there is no third, stamped block to keep in step.
 *
 * The contrast figures behind these values were computed rather than eyeballed;
 * docs/board-layout.md points at where they are recorded. The two tightest
 * pairings in the system are the open mark on its own tint, at 4.64:1 day and
 * 4.70:1 night. Both clear AA, and both are worth knowing about before anyone
 * raises --tint or darkens the day ground.
 *
 * Class names are prefixed fm- so a board body can add its own classes without
 * colliding with the layout contract. Tally's own report calls the two new
 * components `.tally` and `.reserve`; here they keep the prefix, as `.fm-tally`
 * and `.fm-reserve`, because that prefix rule is older than the reground and
 * exempting two components from it is how a collision gets in.
 * docs/board-layout.md lists the components and the markup each one expects.
 */

:root {
  /* -- stock: what the sheet is printed on --------------------------------- */
  --stock-desk:   #D2DACF;   /* the desk the sheet lies on. NO text is ever set on it. */
  --stock-sheet:  #F7F9F4;   /* the sheet */
  --stock-band:   #E3ECDE;   /* the alternating register band; decorative by design */
  --stock-inset:  #EDF1E9;   /* a field cut into the sheet: inputs, code, the tally strip */

  /* -- ink: the record ----------------------------------------------------- */
  --ink-record:   #12191B;   /* what the item is: titles, stakes, values */
  --ink-note:     #3F4C4C;   /* evidence and supporting prose */
  --ink-margin:   #556362;   /* record ids, dates, provenance, captions */

  /* -- rules --------------------------------------------------------------- */
  --rule-feint:   #C6D1C6;   /* divides only; carries NO information, deliberately below 3:1 */
  --rule-scored:  #78877F;   /* any edge that carries meaning; held at >= 3:1 */

  /* -- marks: colour is a claim on the captain, and nothing else ----------- */
  --mark-open:    #B3241C;   /* yours, now. The only red in the system. */
  --mark-gate:    #3A3D9E;   /* answer this one first - it settles others. */
  --mark-held:    #7E5405;   /* waiting outside the fleet. Nobody here can move it. */
  --veil-fold:    #525F6D;   /* folded, unverified, withheld. A property of the SHEET. */

  /* -- chart fills: a separate step of the same three hues -----------------
   * The mark hues are a status palette measured for TEXT contrast and fail a
   * categorical-series check by construction. These were validated as a series
   * instead, so a bar and a chip never borrow each other's job. */
  --fill-1:       #C8452C;
  --fill-2:       #6068E0;
  --fill-3:       #B8830C;
  --fill-neutral: #78877F;   /* the unclaimed remainder in a bar */

  --tint: 0.10;              /* how much of a mark is mixed into a marked surface */

  /* -- type ----------------------------------------------------------------
   * Two families. A form is set on one press, so a third would be decoration.
   * The inversion is deliberate: the machine's DATA is the display type and the
   * form's own captions are the smallest thing on the sheet, which is how a real
   * form reads. The rule that keeps it from becoming a terminal costume:
   * THE MONOSPACE FACE NEVER CARRIES A SENTENCE. It carries names, numbers and
   * labels. It also keeps German compounds out of the widest face. */
  --face-data: ui-monospace, SFMono-Regular, "SF Mono", "Cascadia Mono", "Roboto Mono", Menlo, Consolas, "Liberation Mono", monospace;
  --face-text: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;

  --t-issue:   clamp(1.05rem, 4.2vw, 1.75rem);
  --t-count:   clamp(1.6rem, 5.5vw, 2.5rem);
  --t-heading: clamp(1rem, 2.6vw, 1.3rem);
  --t-entry:   clamp(1rem, 2.2vw, 1.1875rem);
  --t-body:    0.9375rem;
  --t-meta:    0.8125rem;
  --t-data:    0.8125rem;
  --t-caption: 0.6875rem;
  --track-caption: 0.14em;
  --track-issue:   0.06em;
  --measure: 68ch;           /* the hard cap on any running paragraph */

  /* -- space, edge, motion -------------------------------------------------- */
  --sp-1: 4px;  --sp-2: 8px;  --sp-3: 12px; --sp-4: 16px;
  --sp-5: 24px; --sp-6: 32px; --sp-7: 48px; --sp-8: 72px;
  --edge-mark: 2px;          /* the ONLY radius in the system: the answer button */
  --chamfer: 18px;           /* the sheet's orientation corner, once per sheet */
  --lift-sheet: 0 1px 0 rgba(18, 25, 27, .10), 0 8px 24px -12px rgba(18, 25, 27, .22);
  --motion-mark: 140ms;
  --ease: cubic-bezier(.2, .7, .3, 1);
  color-scheme: light dark;
}

/* Night is not an inversion of day. It is the same sheet as a DIAZO PRINT: the
 * ground goes to blue-black and the line work goes pale, which is the honest
 * dark counterpart of a drawing rather than a slate re-skin. */
@media (prefers-color-scheme: dark) {
  :root {
    --stock-desk:   #070C10;
    --stock-sheet:  #111A20;
    --stock-band:   #192731;
    --stock-inset:  #1B2830;
    --ink-record:   #E6EEF0;
    --ink-note:     #A8B8BC;
    --ink-margin:   #8496A0;
    --rule-feint:   #243038;
    --rule-scored:  #647882;
    --mark-open:    #FF7C6B;
    --mark-gate:    #A3AEFF;
    --mark-held:    #E0A83C;
    --veil-fold:    #93A3B4;
    --fill-1:       #D9584A;
    --fill-2:       #7A82E8;
    --fill-3:       #BC8524;
    --fill-neutral: #647882;
    --tint: 0.16;
    --lift-sheet: 0 1px 0 rgba(0, 0, 0, .5), 0 8px 24px -12px rgba(0, 0, 0, .7);
  }
}

/* min-width:0 on every element is what stops a long unbreakable token from
 * pushing a grid or flex child wider than its track. Nested grids inherit it,
 * which is the failure the Lavish guidance calls out specifically. */
* {
  box-sizing: border-box;
  min-width: 0;
}

html {
  -webkit-text-size-adjust: 100%;
}

body {
  margin: 0;
  background: var(--stock-desk);
  color: var(--ink-record);
  font-family: var(--face-text);
  font-size: var(--t-body);
  line-height: 1.6;
  padding: clamp(var(--sp-3), 3vw, var(--sp-6)) clamp(0px, 3vw, var(--sp-6)) var(--sp-6);
  /* Body-level horizontal scrolling is prevented by SIZING - min-width:0
   * everywhere, minmax(0,1fr) tracks, 

... [40267 bytes truncated] ...

er it all happen inside a form, never with a square focused.
  function paintStrip() {
    if (!strip || !stripRow) {
      return;
    }
    var unsent = 0;
    var html = '';
    for (var i = 0; i < QUESTIONS.length; i++) {
      var q = QUESTIONS[i];
      var state = STATE[q.key] || 'blank';
      if (state !== 'struck') {
        unsent++;
      }
      var mark = markFor(state);
      // The struck mark is drawn in ink; the other two are the open red, so a
      // square that still wants something is the loud one.
      var tone = state === 'struck' ? 'fm-mk-struck' : 'fm-mk-open';
      html += '<button class="fm-tally-sq" type="button" data-fm-jump="' + esc(q.jump) +
        '" aria-label="' + esc(squareLabel(i, state)) + '">' +
        '<svg class="fm-mk ' + tone + '" aria-hidden="true" focusable="false">' +
        '<use href="#fm-mk-' + mark + '"></use></svg></button>';
    }
    stripRow.innerHTML = html;
    stripNumber.textContent = String(unsent);
    stripCaption.textContent = unsent === 0
      ? T.allSent
      : T.of + QUESTIONS.length + T.notSent;
    if (unsent === 0) {
      stripCount.classList.add('is-clear');
    } else {
      stripCount.classList.remove('is-clear');
    }
  }

  function setState(form, state) {
    var key = questionKey(form);
    if (!key || !(key in STATE)) {
      return;
    }
    if (STATE[key] === state) {
      return;
    }
    STATE[key] = state;
    paintStrip();
  }

  // A change is a change of mind, whatever came before it. An answer edited
  // after it was sent has NOT been sent in its current form, so its square
  // returns to pencil and the count goes back up with it.
  function onChange(event) {
    var el = event.target;
    if (!el || typeof el.closest !== 'function') {
      return;
    }
    var form = el.closest('form[data-fm-question]');
    if (form) {
      setState(form, 'pencil');
    }
  }

  function onStripClick(event) {
    var el = event.target;
    if (!el || typeof el.closest !== 'function') {
      return;
    }
    var square = el.closest('.fm-tally-sq');
    if (!square) {
      return;
    }
    var id = square.getAttribute('data-fm-jump');
    if (!id) {
      return;
    }
    var target = document.getElementById(id);
    if (target && typeof target.scrollIntoView === 'function') {
      target.scrollIntoView({ behavior: 'smooth', block: 'start' });
    }
  }

  function onSubmit(event) {
    var form = event.target;
    if (!form || !form.matches || !form.matches('form[data-fm-question]')) {
      return;
    }
    event.preventDefault();

    var answer = answerOf(form);
    if (!answer) {
      // Nothing to send, so nothing left the board and the count does not move.
      markUnanswered(form);
      return;
    }

    var key = form.getAttribute('data-fm-question');
    var label = form.getAttribute('data-fm-label') || key;
    var lavish = bridge();
    if (!lavish) {
      // Opened without a running Lavish server: there is nowhere to queue to.
      // Say so rather than pretending the answer was recorded, and leave the
      // square unstruck, because it genuinely was not sent.
      revealOffline();
      return;
    }

    lavish.queuePrompt(promptFor(label, answer), {
      tag: 'decision',
      text: label + ': ' + (answer.choice || 'note'),
      // queueKey is what makes a changed answer REPLACE the previous unsent one.
      queueKey: key,
      element: form,
      data: { question: key, answer: answer.choice, note: answer.note }
    });
    // The queue just worked, so any offline notice on this board is stale.
    showOffline(false);
    markQueued(form, answer);
    // Only here. This is the completed submit, and the only transition that
    // takes a square out of the count.
    setState(form, 'struck');
  }

  function showOffline(show) {
    var notes = document.querySelectorAll('.fm-offline');
    for (var i = 0; i < notes.length; i++) {
      if (show) {
        notes[i].classList.add('is-shown');
      } else {
        notes[i].classList.remove('is-shown');
      }
    }
  }

  function revealOffline() {
    showOffline(true);
  }

  // Lavish injects its own runtime, and this inlined script may run before that
  // injection lands. A single check at startup would therefore report a served
  // board as offline. Poll briefly instead, and keep checking at a slower
  // cadence after the notice appears, so a runtime that lands late takes the
  // notice back down rather than leaving the captain reading that his answers
  // cannot be sent back while they are in fact being sent.
  //
  // The poll gives up after about 122 seconds - 6 tries at 400ms, then 60 at
  // 2000ms. A runtime that lands after that leaves the notice standing until the
  // captain's first successful submit clears it; the submit path is the backstop
  // here, not the poll.
  function watchForBridge(quietLeft, slowLeft) {
    if (bridge()) {
      showOffline(false);
      return;
    }
    if (quietLeft > 0) {
      window.setTimeout(function () {
        watchForBridge(quietLeft - 1, slowLeft);
      }, 400);
      return;
    }
    revealOffline();
    if (slowLeft <= 0) {
      return;
    }
    window.setTimeout(function () {
      watchForBridge(0, slowLeft - 1);
    }, 2000);
  }

  // docs/board-layout.md states two rules a board body has to follow: the radio
  // name equals data-fm-question, and every question form carries a .fm-queued
  // box. Neither can be enforced from prose, and a board that breaks either one
  // builds and renders, so both are named on the console at startup. Breaking
  // the first costs nothing, because answerOf falls back to the form's own
  // checked control; breaking the second is what would make a submit silent.
  function reportFormDefects(form) {
    if (!window.console || !window.console.warn) {
      return;
    }
    var key = form.getAttribute('data-fm-question');
    if (form.querySelector && !form.querySelector('.fm-queued')) {
      window.console.warn('fm-board: the form for "' + key +
        '" has no .fm-queued box, so nothing on it can report a queued or an empty answer.');
    }
    if (form.querySelector && !form.querySelector('[data-fm-note]')) {
      // A note beside a selection is not decoration: measured on the board of
      // 2026-08-16, two of twenty answers carried a note that contradicted the
      // chosen option and held what the captain actually meant.
      window.console.warn('fm-board: the form for "' + key +
        '" has no note field, so the captain can only pick an option and cannot say what he meant.');
    }
    if (!form.querySelectorAll) {
      return;
    }
    var radios = form.querySelectorAll('input[type="radio"]');
    for (var i = 0; i < radios.length; i++) {
      if (radios[i].name && radios[i].name !== key) {
        window.console.warn('fm-board: radio name "' + radios[i].name +
          '" does not match data-fm-question "' + key +
          '"; the answer is read from the form instead.');
        return;
      }
    }
  }

  function init() {
    pickLanguage();
    QUESTIONS = [];
    STATE = {};
    // The playbook wants data-lavish-question on the question wrapper. Derive it
    // from data-fm-question so a board body declares the key exactly once.
    var forms = document.querySelectorAll('form[data-fm-question]');
    for (var i = 0; i < forms.length; i++) {
      var key = forms[i].getAttribute('data-fm-question');
      if (!forms[i].hasAttribute('data-lavish-question')) {
        forms[i].setAttribute('data-lavish-question', key);
      }
      reportFormDefects(forms[i]);
      if (key && !(key in STATE)) {
        QUESTIONS.push({ key: key, label: questionLabel(forms[i]), jump: jumpTarget(forms[i]) });
        STATE[key] = 'blank';
      }
    }
    buildStrip();
    watchForBridge(6, 60);
  }

  document.addEventListener('submit', onSubmit);
  document.addEventListener('change', onChange);
  document.addEventListener('input', onChange);
  document.addEventListener('click', onStripClick);

  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', init);
  } else {
    init();
  }
}());
</script>
</body>
</html>
Evidence: Live selftest transcript

Live selftest log shows: poll listening: yes before answering; screenshot captured; decision 1 queued option-eins with note; Send to Agent clicked; poll returned the decision, option, and note; all nine hops held.


== 1/9 build the fixture board
board: /tmp/no-mistakes-evidence/01M0BKKC571XXK181ZGY7XZETA/live-selftest-1787096568208737903/tmp/fm-run-decisionboard.P1e6aO/probe-board.html
ok - board built

== 2/9 the no-network guard still bites
ok - the no-network guard refused a CDN body (exit 1) and wrote nothing

== 3/9 open it
   http://crew-hlr.tail7b8448.ts.net:4448/session/412b6cca39d10d99
ok - the board is served on a reachable address

== 4/9 drive a browser to it
driving: Probebrett · Lavish

== 5/9 arm the poll before answering

== 6/9 what the rendered board carries
board:
  decision cards: 2
  with selectable options: 2
  with a note field: 2
  with a button (role only): 2
  button evidence: necessary shape only; role=button cannot distinguish submit from help or reset. Only answer proves submission by clicking it and reading the queued confirmation.
  poll listening: yes
options:
  1: Option eins Die Option, die der Treiber wählt.
  1: Option zwei Bleibt ungewählt.
  2: Ja
  2: Nein

== 7/9 screenshot it
screenshot: /tmp/no-mistakes-evidence/01M0BKKC571XXK181ZGY7XZETA/live-selftest-1787096568208737903/tmp/fm-run-decisionboard.P1e6aO/probe-board.png (151619 bytes)

== 8/9 answer one decision
ok - decision 1 queued 'OPTION-EINS' with a note
ok - queued answers sent

== 9/9 prove the answer comes back
ok - the poll returned the decision, option, and note
   answer: "Entscheidung \"Probe-Entscheidung A\": option-eins - Anmerkung des Captains: Vom run-decisionboard-Treiber gesetzt\n\nContext data:\n{\n  \"question\": \"probe-a\",\n  \"answer\": \"option-eins\",\n  \"note\": \"Vom run-decisionboard-Treiber gesetzt\"\n}","body > div > div:nth-of-type(2) > div:nth-of-type(1) > form",decision,"Probe-Entscheidung A: option-eins"
ok - session ended

kept: /tmp/no-mistakes-evidence/01M0BKKC571XXK181ZGY7XZETA/live-selftest-1787096568208737903/tmp/fm-run-decisionboard.P1e6aO

all nine hops held: a decision board built here can be answered, and the answer comes back.

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

🔧 **Review** - 2 issues found → auto-fixed ✅
  • 🚨 .agents/skills/run-decisionboard/fm-run-decisionboard.sh:534 - The default screenshot target is still only keyed by $$, so old bridge-owned staging files can collide again after PID reuse. Concrete path: a successful selftest leaves /tmp/fm-run-decisionboard-shot.&lt;user&gt;.&lt;pid&gt;.png behind when rm -f cannot delete the bridge-owned file; later the shell gets the same PID, captures the same board pixels, before == after, and capture_staged_screenshot returns stale result 2 even though this run refreshed the file. That leaves the stated stale-target false negative reachable. Use a per-invocation value with practical collision resistance for the default target, while keeping FM_RUN_DECISIONBOARD_SHOT_ID for tests.
  • ⚠️ tests/fm-run-decisionboard.test.sh:329 - This newly added test is source-content-only: it extracts cmd_selftest() with sed, greps for helper names and literal text, and asserts their source order. Under the test-quality rule, that does not prove behavior and will fail on behavior-preserving refactors while passing dead/commented code. Replace it with an executable-interface test, for example fake fm-lavish.sh/chrome-devtools-axi interactions and assert that selftest starts poll before answer/send and refuses to answer unless query observes poll listening: yes.

🔧 Fix: Harden decisionboard selftest regressions
✅ Re-checked - no issues remain.

✅ **Test** - passed

✅ No issues found.

  • bash tests/fm-run-decisionboard.test.sh
  • attempted .agents/skills/run-decisionboard/fm-run-decisionboard.sh selftest --keep with isolated evidence dirs; setup failed before building because FM_BOARD_VESSEL was unset
  • FM_BOARD_VESSEL=test-phase TMPDIR=/tmp/no-mistakes-evidence/01M0BKKC571XXK181ZGY7XZETA/live-selftest-*/tmp XDG_RUNTIME_DIR=/tmp/no-mistakes-evidence/01M0BKKC571XXK181ZGY7XZETA/live-selftest-*/xdg FM_RUN_DECISIONBOARD_TMPDIR=/tmp/no-mistakes-evidence/01M0BKKC571XXK181ZGY7XZETA/live-selftest-*/shot-staging .agents/skills/run-decisionboard/fm-run-decisionboard.sh selftest --keep
  • visual inspection of /tmp/no-mistakes-evidence/01M0BKKC571XXK181ZGY7XZETA/run-decisionboard-selftest.png
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

@Freudator86
Freudator86 merged commit 7b541a7 into main Aug 19, 2026
10 checks passed
@Freudator86
Freudator86 deleted the fm/fm-decisionboard-answer-path-broken branch August 19, 2026 07:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant