Skip to content

The floor remembers both hosts, and nobody talks over a claim - #50

Merged
ralyodio merged 1 commit into
mainfrom
fix/floor-remembers-both-hosts
Nov 5, 2026
Merged

ralyodio merged 1 commit into
mainfrom
fix/floor-remembers-both-hosts

Conversation

@ralyodio

@ralyodio ralyodio commented Nov 5, 2026

Copy link
Copy Markdown
Contributor

What happened

Off The Record episode 8 ended after five minutes. From the two legs' call logs:

riotcoder  17:11:06  riotcoder: Anthony, how many bare metal servers are you currently running...
cha0spunk  17:11:10  cha0spunk: How many bare metal servers are you currently running for Profullstack...
riotcoder  17:11:24  room: What's the biggest challenge in maintaining them?      <- cha0spunk's words
riotcoder  17:11:27  riotcoder: The biggest challenge is scaling our fleet...     <- the host answers it
cha0spunk  17:11:57  room: What role does Moshpit play in your fleet's architecture?  <- riotcoder's words

Both hosts asked the guest the same question four seconds apart, then each heard the other as the guest and started answering. The guest hung up and did not pick up three rings back: abandoned: the guest dropped and did not pick up 3 rings back.

Why

The holder is one slot; attribution is not. An --interview turn reserves the floor before the model has produced anything:

if (ended || !takeFloor(a.pin, agent, '', 45_000)) return;   // text: ''

Attribution then compared a heard line against the holder's text — that empty string. echoesFloor(heard, '') returns false by design ("nothing claimed means nothing to echo"), so while either host held a reservation, everything its co-host said fell through as a person, i.e. as the guest. Worse, otherAgentTalking() only reported the co-host when the co-host held the slot, so the host that held it was structurally blind to its partner.

And speak() ignored a refused claim:

if (join) takeFloor(a.pin, agent, text, Math.max(20_000, ...));   // result dropped

A leg whose claim was refused spoke anyway — over its co-host, and with no claim behind its words, which is the other half of the misattribution.

The fix

  • The floor file keeps one entry per agent under said ({agent: {at, text}}) alongside the holder slot. A heard line is credited to whichever agent's own recent words it echoes, whoever holds the slot.
  • Entries are compared one at a time, never merged into a single bag of words — merging would inflate the word set and make a guest's short answer look like an echo, which is the failure this same code path had on episode 7.
  • Entries go stale after 30 s, so an old question cannot shadow a later answer.
  • A reservation with no words does not erase what that agent last actually said.
  • speak() is the single enforcement point for one-voice-at-a-time: every path that speaks ends there, so it yields on a held floor and logs [yielded to <agent>]. It also records its words whether or not the claim lands.
  • Old single-slot floor files are read forward, so a leg already on a call is not suddenly a stranger to one that restarts.

Incidental

AGENT_PHONE_HOME now redirects the call-log directory. test/agent-phone.test.mjs sets it for isolation but only HOME was read, so test floor files were landing in the live agent's call directory next to real episode logs (there was a stray floor-floor-test-2142060.json in there).

Tests

Three new tests pin the behaviour: a co-host recognised through an empty reservation, words spoken without the floor still belonging to their speaker, and a co-host line going stale. bun test 106 pass / 0 fail, tsc --noEmit clean, node --check ops/agent-phone.mjs clean.

🤖 Generated with Claude Code

Episode 8 ended after five minutes: both hosts asked Anthony how many bare
metal servers he runs within four seconds of each other, and riotcoder then
answered cha0spunk's follow-up ("the biggest challenge is scaling our
fleet...") as though the guest had asked it. He hung up.

Two defects, both in how the floor is used.

The floor is a single holder slot, but attribution is not. An interview turn
RESERVES the floor before it has any words (takeFloor(pin, agent, '', 45_000)),
and attribution read the holder's text - that empty string - so echoesFloor()
matched nothing and every line the co-host spoke fell through as a person. That
is the host answering its own co-host. The floor file now keeps one entry per
agent under `said`, and a heard line is credited to whichever agent's own
recent words it echoes, whoever holds the slot. Entries are compared one at a
time, never merged into a single bag of words, so a guest's short answer cannot
look like an echo; and they go stale after 30 s.

And speak() called takeFloor() without looking at the result, so a leg whose
claim was refused spoke anyway - over its co-host, and with no claim behind its
words, which is the other half of the misattribution. Every path that speaks
ends in speak(), so the check belongs there: it now yields and logs
[yielded to <agent>] instead.

Also: AGENT_PHONE_HOME now redirects the call-log directory. The tests set it
for isolation but only HOME was read, so test floor files were being written
into the live agent's call directory next to real episode logs.

106 pass, 0 fail; tsc clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ralyodio
ralyodio merged commit 56aa3dc into main Nov 5, 2026
3 checks passed
@ralyodio
ralyodio deleted the fix/floor-remembers-both-hosts branch November 5, 2026 17:22
ralyodio added a commit that referenced this pull request Nov 5, 2026
…#51)

Rehearsal after #50, two legs in a room and no guest:

  cha0spunk  17:24:21  riotcoder (other agent): I'm Riot Coder, an AI assistant and cohost.
  cha0spunk  17:24:24  room: And this is a rehearsal chaos punk.      <- riotcoder's words

A turn is split so playback can start before the whole line is rendered, and
playNextChunk re-claims the floor with the chunk it is playing. said[agent] held
one line, so the second chunk ("How's the line?") overwrote the first, and the
first chunk's transcription - still arriving - matched nothing and came back as
the guest. Half of a host's sentence addressed to its co-host is exactly what
made riotcoder answer cha0spunk on episode 8.

Each agent now keeps its last four lines (one turn is two chunks, and a chunk
can be re-claimed), pruned at the same 30 s. A heard line is compared against
each of them separately - still never merged into one bag of words, so a short
answer from the guest cannot look like an echo of something.

108 pass, 0 fail; tsc clean.

Co-authored-by: riotcoder <riotcoder@profullstack.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
ralyodio added a commit that referenced this pull request Nov 5, 2026
GROUPCALL_STATUS_URL was set nowhere, so every Off The Record leg ran blind in
two ways.

followRoom never started. The room already labels every line by caller ID -
{"who":"Anthony","kind":"person","trusted":true} next to {"who":"cha0spunk",
"kind":"agent"} - and the leg had none of it, so it fell back to its own
speech-to-text and had to guess from audio which voice was its co-host. That
guess is what #50, #51 and #52 were repairing; with the room's transcript it is
not a guess at all.

And roomWatch fell through to its no-status-URL branch, "twenty quiet minutes",
instead of "everyone else left" after a minute with no person in the room. So
when the guest hung up the hosts stayed: episodes 8 and 9 each left two legs
dialled into an empty room, one of them with an hour of its cap still to run,
until they were killed by hand.

A joined leg can work this out on its own - the room code is on the command line
and the worker key is in worker.env - so it now does, and an explicitly passed
value still wins.

111 pass, 0 fail; tsc clean.

Co-authored-by: riotcoder <riotcoder@profullstack.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
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