`/heid-bug-hunt` on U2's diff, four arms, artifact-only. Eight findings were real against live code; a ninth was already closed by v0.2.1 and is recorded as declined. Full triage in persistent-memory.d/2026-09-22-bug-hunt-panel.md. THE LOCK LIFECYCLE (4/4 convergent, and two defects in one place) `_Locked.__exit__` unlinked `.marks.lock` on the no-op path so a booth that had never been marked was left exactly as it was found. `flock` binds to an INODE: unlinking it under a blocked waiter leaves that waiter holding an exclusive lock on a deleted file while the next writer creates a fresh lock and takes it immediately. Two processes then run the read-modify-write concurrently, the later os.replace drops the earlier one's mark, and both obeyed the protocol. The cleanup existed to protect the booth's TTL, and was failing at that too: creating or removing a directory entry bumps the DIRECTORY's mtime, which is what `_newest_mtime` seeds from. The guard's comment reasons about the lock file's own mtime and misses that the directory moved underneath it. One fix: never unlink the lock, exempt `.<name>.lock` dotfiles from `_newest_mtime`, and restore the directory's mtime after creating one. THE READ PATH'S BLAST RADIUS `_clean_text` did `(text or "").replace(...)` and `marks_for` sorts on `(created, id)`, so a stored `text` that was a dict or a `created` that was a number raised out of the read path. `list_booths` reads every booth's marks on every index load, so one hand-edited file returned 500 for `/` and `/healthz` across all 25 booths. Guarded in two layers — a named type check and a `_hydrate_safe` backstop that cannot raise — and an unreadable mark now renders as ⚠ broken rather than as an empty note. ALSO - import_legacy_asks stamped `created` at whole-second resolution, so two sidecars from the same second lost the ordering the importer had just established and re-sorted alphabetically. Microseconds, per the stated `(mtime, name)` rule. - The five mark-write routes ran a blocking flock on the event loop; they now dispatch through run_in_threadpool, asserted structurally like INV-1. - `/answer` 500'd on a non-string `notes` form value where `/note` handled it. - The inline-doc tile had a flag control and no note field. - The marks panel was suppressed on any booth carrying a links.md. - The viewer's arrow keys and Escape threw away a note being typed. CLI `booth marks` printed a traceback and exited 0 on a failed read, and `--wait` emitted a whole JSON document per poll. `booth answer --wait` read a damaged file as "not yet" and spun the full hour. Both now use real exit codes — 0 ok, 1 unanswered/timed-out, 2 no such pick, 3 unreadable — and `--wait` prints once. `marks.read_error()` lets the CLI ask what the page must not: the browser stays lenient, the machine consumer gets the truth. `scripts/booth` had no tests; it has five now, run against the real script under the system python3, which also makes them a live check on INV-1. 275 tests (253 before). Live service restarted, 25/25 booth pages verified 200.
160 lines
8.8 KiB
HTML
160 lines
8.8 KiB
HTML
{# Shared MARKS panel — included by booth.html (auto-gallery view) and by
|
||
marks.html (the standalone page a VERBATIM index.html booth links to, since
|
||
a verbatim page is served as-is and can never render this inline).
|
||
|
||
One primitive, three shapes, one slot:
|
||
pick — a session declared N options; the operator chooses. Renders as a
|
||
radio form; answering POSTs to /answer and rewrites .marks.json.
|
||
note — free text the operator volunteered, in either direction.
|
||
flag — the operator pointing at one item. Rendered on the item's tile
|
||
rather than here, so the judgment sits beside the artifact; the
|
||
count below is the way back to them.
|
||
|
||
Works with JS off — plain form POST, every shape. An answered pick shows the
|
||
recorded judgment and a collapsed "change" form, because the mark is the
|
||
CURRENT judgment and not a log. #}
|
||
{# A mark carrying `error` is sorted out FIRST, whatever shape it claims. A
|
||
pick keeps its own ⚠ broken rendering below (richer — it has a declaration to
|
||
show); a broken note would otherwise render as an empty <pre> with a withdraw
|
||
button, indistinguishable from a note the operator wrote and then cleared,
|
||
and a broken flag would link to a target that is not there. Unreadable state
|
||
is visible state — the rule `_hydrate` states for picks, applied to all
|
||
three. #}
|
||
{% set broken = marks | selectattr('error') | rejectattr('shape', 'equalto', 'pick') | list %}
|
||
{% set picks = marks | selectattr('shape', 'equalto', 'pick') | list %}
|
||
{% set notes = marks | selectattr('shape', 'equalto', 'note') | rejectattr('error') | list %}
|
||
{% set flags = marks | selectattr('shape', 'equalto', 'flag') | rejectattr('error') | list %}
|
||
<section class="marks">
|
||
|
||
{% for a in broken %}
|
||
<article class="mark mark-note is-broken" id="mark-{{ a.id }}">
|
||
<header class="mark-head">
|
||
<span class="mark-state">⚠ broken</span>
|
||
<span class="mark-id"><code>{{ a.id }}</code></span>
|
||
<span class="board-spacer"></span>
|
||
<form class="mark-undo" method="post" action="/b/{{ name_url }}/unmark">
|
||
<input type="hidden" name="mark" value="{{ a.id }}">
|
||
{% if marks_page %}<input type="hidden" name="back" value="marks">{% endif %}
|
||
<button type="submit" class="mark-x" title="withdraw this mark">×</button>
|
||
</form>
|
||
</header>
|
||
<p class="mark-error">This mark could not be read: {{ a.error }}</p>
|
||
</article>
|
||
{% endfor %}
|
||
|
||
{% for a in picks %}
|
||
<article class="mark mark-pick{% if a.answer and a.answer.complete %} is-answered{% elif a.answer %} is-partial{% elif a.error %} is-broken{% endif %}" id="mark-{{ a.id }}">
|
||
<header class="mark-head">
|
||
<span class="mark-state">{% if a.error %}⚠ broken{% elif a.answer and a.answer.complete %}✓ answered{% elif a.answer %}◐ partial{% else %}? open{% endif %}</span>
|
||
<span class="mark-id"><code>{{ a.id }}</code>{% if a.multi %} · {{ a.questions|length }} questions{% endif %}</span>
|
||
{% if a.target %}<span class="mark-target">on <a href="view?f={{ a.target|urlencode }}">{{ a.target }}</a></span>{% endif %}
|
||
<span class="board-spacer"></span>
|
||
{% if a.answer and not a.answer.complete %}<span class="mark-part">{{ (a.questions|length) - (a.answer.unanswered|length) }}/{{ a.questions|length }}</span>{% endif %}
|
||
{% if a.answer %}<span class="mark-when">{{ a.answer.answered_at }}{% if a.answer.answered_by %} · {{ a.answer.answered_by }}{% endif %}</span>{% endif %}
|
||
</header>
|
||
{% if a.error %}
|
||
<p class="mark-error">This question could not be read: {{ a.error }}</p>
|
||
{% else %}
|
||
{% if a.title and not a.multi %}<p class="mark-title">{{ a.title }}</p>{% endif %}
|
||
<p class="mark-prompt">{{ a.prompt }}</p>
|
||
{% if a.answer %}
|
||
<div class="mark-answer">
|
||
{% if a.multi %}
|
||
{% for q in a.questions %}{% set qa = a.answer.answers.get(q.key) %}
|
||
<div class="mark-answer-q">
|
||
<span class="mark-answer-qprompt">{{ q.prompt }}</span>
|
||
<div class="mark-answer-choice{% if not (qa and qa.choice is not none) %} is-skipped{% endif %}">{{ qa.label if (qa and qa.choice is not none) else 'left blank' }}</div>
|
||
{% if qa and qa.notes %}<pre class="mark-answer-notes">{{ qa.notes }}</pre>{% endif %}
|
||
</div>
|
||
{% endfor %}
|
||
{% else %}
|
||
<div class="mark-answer-choice">{{ a.answer.label }}</div>
|
||
{% endif %}
|
||
{% if a.answer.notes %}<pre class="mark-answer-notes">{{ a.answer.notes }}</pre>{% endif %}
|
||
</div>
|
||
{% endif %}
|
||
<details class="mark-formwrap"{% if not a.answer %} open{% endif %}>
|
||
<summary class="mark-change">{% if a.answer %}change answer{% else %}answer{% endif %}</summary>
|
||
<form class="mark-form" method="post" action="/b/{{ name_url }}/answer">
|
||
{# The field is still `ask`: inline fragments in reports the operator
|
||
has already published POST that name, and breaking every landed
|
||
verbatim report to tidy a form field is not a trade worth making. #}
|
||
<input type="hidden" name="ask" value="{{ a.id }}">
|
||
{# On the standalone page, come back HERE — the booth's own page is a
|
||
verbatim report that cannot show the recorded judgment. #}
|
||
{% if marks_page %}<input type="hidden" name="back" value="marks">{% endif %}
|
||
{% for q in a.questions %}
|
||
{% set field = 'choice.' ~ q.key if a.multi else 'choice' %}
|
||
{% set qa = a.answer.answers.get(q.key) if (a.answer and a.multi) else a.answer %}
|
||
<fieldset class="mark-q">
|
||
{% if a.multi %}<legend class="mark-q-prompt">{{ loop.index }}. {{ q.prompt }}</legend>{% endif %}
|
||
<div class="mark-options">
|
||
{% for o in q.options %}
|
||
<label class="mark-opt{% if qa and qa.choice == o.id %} is-current{% endif %}">
|
||
<input type="radio" name="{{ field }}" value="{{ o.id }}"
|
||
{% if qa and qa.choice == o.id %}checked{% endif %}>
|
||
<span class="mark-opt-main">
|
||
<span class="mark-opt-label">{{ o.label }}</span>
|
||
{% if o.detail %}<span class="mark-opt-detail">{{ o.detail }}</span>{% endif %}
|
||
</span>
|
||
</label>
|
||
{% endfor %}
|
||
</div>
|
||
{% if q.notes %}
|
||
<textarea class="mark-notes mark-qnotes" name="notes.{{ q.key }}" rows="2" placeholder="notes on this one (optional)">{{ qa.notes if qa else '' }}</textarea>
|
||
{% endif %}
|
||
</fieldset>
|
||
{% endfor %}
|
||
{% if a.notes_enabled %}
|
||
<textarea class="mark-notes" name="notes" rows="3" placeholder="{{ a.notes_label }} (optional)">{{ a.answer.notes if a.answer else '' }}</textarea>
|
||
{% endif %}
|
||
<div class="mark-actions">
|
||
<button type="submit" class="mark-submit">{% if a.answer %}Update answer{% else %}Submit answer{% endif %}</button>
|
||
</div>
|
||
</form>
|
||
</details>
|
||
{% endif %}
|
||
</article>
|
||
{% endfor %}
|
||
|
||
{% for a in notes %}
|
||
<article class="mark mark-note" id="mark-{{ a.id }}">
|
||
<header class="mark-head">
|
||
<span class="mark-state mark-state-note">note</span>
|
||
{% if a.target %}<span class="mark-target">on <a href="view?f={{ a.target|urlencode }}">{{ a.target }}</a></span>
|
||
{% else %}<span class="mark-target">on this booth</span>{% endif %}
|
||
<span class="board-spacer"></span>
|
||
<span class="mark-when">{{ a.created }}{% if a.by %} · {{ a.by }}{% endif %}</span>
|
||
<form class="mark-undo" method="post" action="/b/{{ name_url }}/unmark">
|
||
<input type="hidden" name="mark" value="{{ a.id }}">
|
||
{% if marks_page %}<input type="hidden" name="back" value="marks">{% endif %}
|
||
<button type="submit" class="mark-x" title="withdraw this note">×</button>
|
||
</form>
|
||
</header>
|
||
<pre class="mark-text">{{ a.text }}</pre>
|
||
</article>
|
||
{% endfor %}
|
||
|
||
{% if flags %}
|
||
<article class="mark mark-flags" id="mark-flags">
|
||
<header class="mark-head">
|
||
<span class="mark-state mark-state-flag">✔ flagged</span>
|
||
<span class="mark-id">{{ flags|length }} item{{ '' if flags|length == 1 else 's' }}</span>
|
||
</header>
|
||
<ul class="mark-flaglist">
|
||
{% for a in flags %}
|
||
<li><a href="view?f={{ a.target|urlencode }}">{{ a.target }}</a></li>
|
||
{% endfor %}
|
||
</ul>
|
||
</article>
|
||
{% endif %}
|
||
|
||
{# The operator volunteering a remark, which before marks had no mechanism at
|
||
all — this is the direction that was running through chat. #}
|
||
<form class="mark-add" method="post" action="/b/{{ name_url }}/note">
|
||
{% if marks_page %}<input type="hidden" name="back" value="marks">{% endif %}
|
||
<textarea name="text" rows="2" placeholder="a note on this booth, for the session that posted it"></textarea>
|
||
<button type="submit">Add note</button>
|
||
</form>
|
||
</section>
|