self-write: the seat's own identity writer, its fence, and a seat-local lock (#1152) - #1157
Merged
Merged
Conversation
This was referenced Sep 11, 2026
self-proof: a live owner pid without its instance marker is undetermined, never proved (#1187)
#1191
Merged
fujibee
added a commit
that referenced
this pull request
Sep 13, 2026
Wires the matching core into the shape #1157's fix entry point calls when process ancestry (#1154) cannot establish this seat's location: emit a token, look for it across every pane the fleet census (agmsg_terminal_enumerate, #1155) can reach, and report where it landed. Same one-line 'state<TAB>payload' shape as agmsg_self_proof, so a caller composing the two never reads a different answer from one than the other. Deliberately never emits disproved (rc 1): an emitted token this pass could not find is not evidence the seat is nowhere (render lag, an unreadable pane, or a scan depth it scrolled past are all still open per #1124's own measurements) -- only a completed ancestry walk gets to claim a real negative. Tested against fakes for agmsg_terminal_enumerate / agmsg_terminal_load / terminal_peek / agmsg_locator_compose (terminal-registry.sh), matching #1155's own fixture style, so none of it touches a real terminal: unsupported with no census primitive, undetermined for an enumeration failure / nothing observed / nothing readable, proved on a single match, and undetermined (never proved) on an ambiguous match. The ambiguous-vs- proved branch was manually mutated (collapsed to proved) and confirmed to turn its test red, then restored. Function name and contract agreed with the #1157 owner before landing.
…at-local lock (#1152) One path writes a seat's own cells -- placement record, pane label, agent key, session name -- for the calling seat only, from a pane handed in by the channel. The record gains a fourth field, fence=<instance>:<terminal_id>, read once per generation through the new optional driver op terminal_fence and re-read before every later mutation; a mismatch refuses that mutation visibly. The session rename is typed once, unconditionally, when the pane is ready: no pre-read skip, mark or flag. Policy is decided in one place from the record cell alone. The generation runs under a seat-local single-flight lock built on the actas lock's core, which now takes a lock path and owner. Extracting it surfaced and fixes, for both locks: an ownerless reclaim mutex that outlived a crashed reclaimer forever, a tombstone restore that deleted the only inode on a failed link, and mutex helpers whose nonzero status killed a set -e caller before any verdict was printed. Closes #1137.
…requires
The library opened with : "${SKILL_DIR:?}" and then read $SKILL_DIR bare seven
times. The unguarded-env-reads checker does not treat that opening line as a
guard, so the PR grew the baseline by seven. Each read now carries the guard
itself, the form the sibling lock library already used.
…p enumeration does The fence's instance half was the herdr session NAME from HERDR_SESSION. The sweep that will hand a seat its pane enumerates instances by SOCKET PATH (one running session per socket, reached with HERDR_SOCKET_PATH=<sock>), so a location the sweep delivers and the fence the seat stores would not compare as equal strings. The herdr driver now reports HERDR_SOCKET_PATH as the instance, refusing an empty or malformed one visibly, and the stored fence is split on its LAST colon so a path instance is safe. Tests drive a socket-path instance.
…river guarantees The stored fence is instance:terminal_id. The herdr driver refuses an instance containing a colon (unknown:socket_path_malformed), so the instance is colon-free by construction; the terminal_id is a server-issued string whose alphabet is not ours, and a failed read already spells it unknown:<why>. The reader split on the LAST colon, which assumed the opposite guarantee: a tid holding a colon was truncated when stored and every re-read compared unequal to it, refusing every later cell for a pane that had not moved. Split on the first colon, and say which guarantee the code actually holds. Two controls: a tid with colons that does not change lets the later cells proceed (red before this change), and two tids differing before their last colon are still told apart.
…ed run's own output The whole-suite-under-shell-states test reruns the file with every state at once and, on red, narrows by rerunning each state alone. When none of the single states is red -- an interaction, or something outside the states such as an inner run that did not finish -- the narrowing printed five green lines and nothing else, which is what CI showed on 2026-09-13: a red with no test named. The combined run's not-ok block, or its tail when it never reached a verdict, is now printed in that case.
…t never lands in the type The self-write record carries a fourth TAB field, fence=<instance>:<terminal_id>. Eight readers split the record with `read -r ref proj type`, and read puts everything after the third TAB into the last variable: the type became "claude-code<TAB>fence=...", and despawn --force handed that to reset, which then found nothing registered under it and left the registration behind. Each reader now takes a fourth variable (which also absorbs any later field). Controls: despawn --force on a record with the fence field still drops the registration (red on the three-variable reader), and a static test requires every placement-record read in scripts/ to take four variables -- it is what found the eighth reader, in watch.sh.
…ed through its process A plain seat can write the placement record for the locator it was handed and nothing else; the driver's capability hook says why in its own words, so "no adapter in this implementation" is never reported as "the emulator cannot". Before writing, the plain fence observes the tty of the seat's own CLI process (the pid in the owner token, via ps -o tty=), never the environment; it refuses by name when the process has no controlling tty, sits on a different tty than the locator names, or no pid was given, and the writer then writes nothing. The anchor is the tty plus the owning pid and its start time, so a /dev/ttysNNN recycled to a new session cannot pass as an old match. The emulator half is carried as delivered and is not evidence. The fence is re-read right after the record lands and BEFORE the record line is printed: a witness that moved between the two reads is named on the record (readback=mismatch:fence_changed:<field>) and the generation is not accepted, and stdout and the done file say the same thing. Controls: the plain happy path, tty unobservable, tty mismatch, no pid, a recycled tty; the legacy plain sentinel names no place and is refused by name.
…itness is carried when it names the same tty Two review findings. First, plain seats were record-only only when the emulator adapter said it could not name or type; an adapter that can poke would have typed a session rename. The rule is by kind, not by capability: the emulator's identity is not evidence, so no decoration is written on its strength, whatever the adapter can do. Second, the record spawn writes for a plain window carries the boot shell's pid and start time, and teardown needs them after the CLI's pid is gone. The first self-write generation now carries exactly that pair into its own fence when the existing record names the same emulator and tty and both keys are complete; unknown keys are never copied. The re-reads compare against the driver's own anchor, not the carried one.
…proved itself to be `fix` (scripts/fix.sh -> scripts/lib/self-fix.sh) is the sweep command a seat runs on itself. It takes no location, from anyone: identity is the actas locks this session owns; the pane the environment names is only a CANDIDATE handed to agmsg_self_proof (process ancestry); when the proof does not say proved and the emit-and-observe fallback agmsg_token_locate_self (#1188) is present it runs under the same four-state contract; the writer (agmsg_self_write) is reached only on proved, with the pane qualified by the instance the observation went through. Anything else is reported by name with nothing written. Any argument is refused by name: a location passed from outside was measured live to resolve a seat into another seat's pane. Tests spy on the proof, the fallback and the writer and pin what reaches the writer and what never does; each guard was mutated and goes red in the test that names it. SKILL.md gains the `fix` dispatch; the self-write header no longer describes a leader typing a pane in.
…ing-TMUX path under set -u The tmux branch of _fix_locator_of_proof read TMUX unguarded. Under the entry's set -u, a seat with no TMUX in its environment would have killed the command substitution the locator runs in, and the seat line would have come out with an EMPTY state -- neither written nor refused by name. The read now takes a default before the split. The control runs the entry in a real bash -u shell with spies, and was red before the change: the seat line lost its state. The unguarded-env checker is back at its baseline (89).
…GitHub outage produced no run
fujibee
force-pushed
the
feat/1152-self-write
branch
from
September 13, 2026 10:23
2258273 to
75285fa
Compare
fujibee
added a commit
that referenced
this pull request
Sep 13, 2026
* feat(team): add the self-locate-by-token matching core (#1124) Matching step only: given a token and pane text the caller already collected, classify found (exactly one pane) / not_found / ambiguous (more than one pane) -- never silently collapsing ambiguous into the first match, since the issue measured a real case of it (an operator typing the same token by hand into their own pane matched a seat's emission, one occurrence each, textually indistinguishable). Deliberately does not implement the parts the issue's own investigation left open: waiting for the seat's completion signal before reading (reading on a timer misses it), the scan-depth bound per pane (a token scrolls out as more output is appended after it), or serializing concurrent probes. Those stay the caller's responsibility; this file documents them as such rather than inventing answers. agmsg_token_locate_generate keeps the token short and opaque: a ~130-char token was measured to wrap across three lines and defeat exact matching on a narrow pane. * feat(team): add agmsg_token_locate_self, the #1157 fallback entry point Wires the matching core into the shape #1157's fix entry point calls when process ancestry (#1154) cannot establish this seat's location: emit a token, look for it across every pane the fleet census (agmsg_terminal_enumerate, #1155) can reach, and report where it landed. Same one-line 'state<TAB>payload' shape as agmsg_self_proof, so a caller composing the two never reads a different answer from one than the other. Deliberately never emits disproved (rc 1): an emitted token this pass could not find is not evidence the seat is nowhere (render lag, an unreadable pane, or a scan depth it scrolled past are all still open per #1124's own measurements) -- only a completed ancestry walk gets to claim a real negative. Tested against fakes for agmsg_terminal_enumerate / agmsg_terminal_load / terminal_peek / agmsg_locator_compose (terminal-registry.sh), matching #1155's own fixture style, so none of it touches a real terminal: unsupported with no census primitive, undetermined for an enumeration failure / nothing observed / nothing readable, proved on a single match, and undetermined (never proved) on an ambiguous match. The ambiguous-vs- proved branch was manually mutated (collapsed to proved) and confirmed to turn its test red, then restored. Function name and contract agreed with the #1157 owner before landing. * test(team): make the #1124 self-wiring assertions actually enforceable Three [[ ]] substring checks sat in non-last position inside their @test bodies, so on macOS's bash 3.2 a false one would report ok rather than failing the test (measured class, #670) -- caught by check-enforced-assertions.sh (629 vs. baseline 626) and by test_enforced_assertions.bats's own live check against the real tree, both red for the same reason. Switched to grep -Fq, a plain command that fails the test correctly in any position on both interpreters.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Part of #1152. (#1137, the action hook's record lacking project and type, is handled separately; this path refuses to write a record without them and tests that.)
What this adds
The one path by which a seat writes its own identity cells, the lock that keeps two writers on one seat from interleaving, and the entry that reaches it:
fix, which takes no arguments and writes only where the seat has proved itself to be.scripts/lib/self-write.shagmsg_self_write <team> <agent> <ref> <owner>writes, for the calling seat only, in this order and each independently:fence=<instance>:<terminal_id>(existing readers select columns withawk/cut, so the field is invisible to them). Required: it is the only cell a seat's reachability runs through.terminal_namecall, two separate readbacks throughterminal_team_observe.<rename_cmd> <team>-<agent>typed once, unconditionally, when the pane's input is ready. There is no pre-read skip, stored mark or process flag: every one of those was a stale value keyed to skip a needed rename. The title before is a baseline for the delta only.The pane arrives here already proved (by
fix, below); the writer does not derive or verify it.teamandagentcome from the seat's own actas; no argument may name another seat. The library holds no search, no candidate generation and no other-seat resolution, and a test pins that by name.Every fact is one output line (
seat=… fence=… record attempt=… readback=… … policy=…), also written last and atomically torun/self-write-done.<team>__<agent>.policyis decided here only:accepted(record verified),accepted_unverified(record written, readback unavailable), otherwiserepair_incomplete. Decorations never change it.fix:scripts/fix.sh→scripts/lib/self-fix.shfixtakes NO arguments — from a poke, a person, or the skill. A location handed in from outside is the defect this replaces: measured live, a seat whose label had been broken resolved itself through an inherited environment into another seat's pane and wrote that pane into its own mark. So the seat establishes where it is:agmsg_self_proof(process ancestry, feat(self-proof): prove a seat's process is bound to a pane, or say why not (#1152) #1154):proved/disproved/undetermined/unsupported;provedand the emit-and-observe locatoragmsg_token_locate_self(feat(team): add the self-locate-by-token matching core (#1124) #1188) is present, it runs under the same four-state contract;proved, throughagmsg_self_write, with the proof's pane qualified by the instance the observation went through. Anything else is one line withstate=andreason=and nothing written. Any argument is refused by name.Exit 0 when every held seat was written, 2 when at least one was left unwritten, 1 on refusal.
SKILL.mddispatchesfix.The fence:
terminal_fence <id>(new optional driver op)Pane ids repeat across terminal instances (two herdr sessions both have a
w1:p2; tmux has one id space per socket), so a pane id alone can name a live pane in another instance — measured on 2026-09-11 when a repair resolved in one session landed in another's pane. Each generation reads<instance, terminal_id>once, stores it in the record, and re-reads it before every later mutation; a difference in either half refuses that mutation visibly (skipped:fence_mismatch:<field>).skipped:unsupported:plain_record_onlyeven where an emulator adapter could name or type, because the emulator's identity is not evidence. The fence observes the tty of the seat's own CLI process (the pid in the owner token, viaps -o tty=), never the environment; it refuses by name when the process has no controlling tty, sits on a different tty than the locator names, or no pid was given, and the writer then writes nothing. The anchor is the tty plus the owning pid and its start time, so a recycled/dev/ttysNNNcannot pass as an old match; the emulator half is carried as delivered and is not evidence. Label, key and session are reported as unsupported in the driver's own words (feat(terminal): address measured plain emulators #1163).This is best-effort safety, not an atomic fence. The read and the keystroke are separate calls, so a pane closed and reused between them is not caught; a real fence needs the terminal to compare-and-type. What it removes is the accident that actually happened.
scripts/lib/self-write-lock.shand the shared lock coreThe whole generation runs under a seat-local single-flight lock (
run/self-write.<team>__<agent>.lock), separate by name, path and API from the leader/seat exchange lock so that deleting one never deletes the other. A second writer seesbusy:<owner>, never a silent drop.To avoid a second producer of the three-valued verdict, the actas lock's core (write, read back, hard-link publish; verdict; positive-dead-only reclaim) now takes a lock path and owner, and
actas_lock_*are wrappers over it. Two defects surfaced while extracting it and are fixed for both locks:mkdir/rmdirwith no owner, so a reclaimer dying between them left it forever and every later claim spun intounknown:reclaim_contended. It is now an owner-bearing lock of the same kind; a dead reclaimer's mutex is displaced by an atomic rename to a claimant-unique tombstone and settled there — removed only if its owner is still positively dead, linked back otherwise. A failed restore link is not read as "someone published into the gap": the destination is re-read, and only a mutex actually read there lets the tombstone go; otherwise the tombstone is kept and every later claim stops with a named unknown instead of treating the gap as free.set -ecaller of the claim loop died inside it before any verdict was printed. They return 0 on every verdict; the line decides.The spawn-time boot witness is carried
spawnrecords a plain window with the boot shell's pid and start time (boot=,boot_start=), and forced teardown needs them once the CLI's pid is gone. The first self-write generation carries exactly that pair into its own fence when the seat's existing record names the same emulator and tty and both keys are complete; unknown keys are never copied, and the fence re-reads compare against the driver's own anchor, not the carried one.Placement-record readers take the fourth field
Eight readers split the record with
read -r ref proj type;readputs everything after the third TAB into the last variable, so the fence field landed intypeanddespawn --forcehanded that toreset. Each reader now takes a fourth variable, a despawn control pins it, and a static test requires it of every placement-record read inscripts/(it found the eighth reader, inwatch.sh). The fence is re-read right after the record lands and before the record line is printed; a witness that moved is named on the record and the generation is not accepted.Tests
tests/test_self_write.bats(14): the real herdr driver against a fixture-driven fakeherdrthat logs every argv. Accepted path; already-named session gets one rename and readsmatched_no_delta; decorations failing leavepolicy=accepted; missing record fields →repair_incompleteand no record (A placement record written by the action hook has no project or type, and arrange refuses it #1137); record written but unreadable →accepted_unverified; terminal_id changing after the record refuses label/key/session and keeps the record's fence; unreadable fence or no session → nothing written; bad ref / plain ref / empty owner refused; a live writer →none:busy; no pane other than the one handed is touched; no derivation/search by name.tests/test_self_write_lock.bats(32) +tests/test_actas_lock.bats(46): the six lock controls (other process, pid reuse, orphan temp, empty lock, crash mid-cell, exact-owner release), the reclaim-mutex controls (live holder, dead holder, a holder killed while holding via the real code, empty/unreadable/undecidable kept, pid reuse, a dead-then-undecidable flip restored), the tombstone controls (restore link failing with the destination absent / superseded / unreadable, a displacer dying between rename and settle), and an errexit control calling the claim loop as a bare statement in abash -echild.terminal_fence.tests/test_self_fix.bats(13): the proof, the fallback and the writer are spies, and the file pins what reaches the writer and what never does — any argument refused; no seat for the session;proved→ the writer gets the seat, the socket-qualified locator and the lock's owner token;disproved/undetermined→ nothing written and the reason named; the fallback runs only when the proof did not prove, its ownundeterminedis named, and its absence isfallback_absent; no candidate in the environment → the proof is not asked; two held seats each written and another session's lock ignored; herdr with no socket and tmux socket-in-ref locators; and a missingTMUXunder a realset -ushell, where an unguarded read would have killed the substitution and produced a seat line with an empty state.Mutations, one test each, run by hand: eight on
fix(arguments accepted, write on any state, environment as authority, fallback ignored, another session's lock counted as ours, fallback state unchecked, fallback run even when proved; the eighth, dropping the instance, is equivalent because the locator grammar refuses an empty instance and the bare-ref test pins that path), eight on the writer (fence check removed, unavailable counted as accepted, readiness ignored, missing fields still written, ref grammar skipped, lock skipped, a pre-read match skip added, fence field dropped) and eleven on the locks. Each reddens only its own test; the clean tree is green.Known gaps, stated: the tmux fence is exercised only by the sweep, not by a fake; the title readback reuses
agmsg_cli_session_observed, whose screen-scraping form still takes the first matching line, so for a type whose name lives on screen the session cell should be read as undecided rather than trusted — that is the next change, not this one.