Skip to content

self-write: the seat's own identity writer, its fence, and a seat-local lock (#1152) - #1157

Merged
fujibee merged 12 commits into
integration/terminal-driver-v1from
feat/1152-self-write
Sep 13, 2026
Merged

self-write: the seat's own identity writer, its fence, and a seat-local lock (#1152)#1157
fujibee merged 12 commits into
integration/terminal-driver-v1from
feat/1152-self-write

Conversation

@fujibee

@fujibee fujibee commented Sep 11, 2026

Copy link
Copy Markdown
Owner

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.sh

agmsg_self_write <team> <agent> <ref> <owner> writes, for the calling seat only, in this order and each independently:

  • record — the placement record, now with a fourth TAB field fence=<instance>:<terminal_id> (existing readers select columns with awk/cut, so the field is invisible to them). Required: it is the only cell a seat's reachability runs through.
  • label + key — one terminal_name call, two separate readbacks through terminal_team_observe.
  • session<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. team and agent come 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 to run/self-write-done.<team>__<agent>. policy is decided here only: accepted (record verified), accepted_unverified (record written, readback unavailable), otherwise repair_incomplete. Decorations never change it.

fix: scripts/fix.shscripts/lib/self-fix.sh

fix takes 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:

  1. identity — the seats whose actas lock this session owns (a lock names a role, never a pane);
  2. candidate — the pane the environment names, handed to the proof as a candidate only, never as an authority;
  3. proofagmsg_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;
  4. fallback — when the proof did not say proved and the emit-and-observe locator agmsg_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;
  5. write — only on proved, through agmsg_self_write, with the proof's pane qualified by the instance the observation went through. Anything else is one line with state= and reason= 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.md dispatches fix.

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>).

  • herdr: instance = the session the driver talks to, terminal_id = the pane's server-side id (unique across sessions, 52 panes / 0 crossings; changes across a restart, so a stored fence expires with the server and refuses instead of writing into whatever now sits at that id).
  • tmux: instance = the socket, terminal_id = the pane's shell pid.
  • plain: record-only by ruling, not by capability — label, key and session are reported as skipped:unsupported:plain_record_only even 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, 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 recycled /dev/ttysNNN cannot 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.sh and the shared lock core

The 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 sees busy:<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:

  • the reclaim mutex was a bare mkdir/rmdir with no owner, so a reclaimer dying between them left it forever and every later claim spun into unknown: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.
  • the mutex helpers printed a verdict and also returned 1, so a set -e caller 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

spawn records 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; read puts everything after the third TAB into the last variable, so the fence field landed in type and despawn --force handed that to reset. Each reader now takes a fourth variable, a despawn control pins it, and a static test requires it of every placement-record read in scripts/ (it found the eighth reader, in watch.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 fake herdr that logs every argv. Accepted path; already-named session gets one rename and reads matched_no_delta; decorations failing leave policy=accepted; missing record fields → repair_incomplete and 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 a bash -e child.
  • The tmux "every id-taking op reaches the owning server" sweep now includes 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 own undetermined is named, and its absence is fallback_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 missing TMUX under a real set -u shell, 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.

@fujibee fujibee closed this Sep 11, 2026
@fujibee fujibee reopened this Sep 11, 2026
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).
@fujibee
fujibee force-pushed the feat/1152-self-write branch from 2258273 to 75285fa Compare September 13, 2026 10:23
@fujibee
fujibee merged commit bfb8621 into integration/terminal-driver-v1 Sep 13, 2026
27 checks passed
@fujibee
fujibee deleted the feat/1152-self-write branch September 13, 2026 18:23
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.
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