Skip to content

fix(presence): stop heartbeats handing over the agent-name registration lock - #84

Merged
andrei-hasna merged 1 commit into
mainfrom
fix/b4f36965-restore-registration-lock
Aug 4, 2026
Merged

fix(presence): stop heartbeats handing over the agent-name registration lock#84
andrei-hasna merged 1 commit into
mainfrom
fix/b4f36965-restore-registration-lock

Conversation

@andrei-hasna

@andrei-hasna andrei-hasna commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Reverts the five CLI call-site changes from #83 and replaces its test file with one that protects the lock those changes opened.

Task: b4f36965 (OPE4-00118)

What broke

agent_presence.session_id is the holder token of the agent-name registration lock, not a provenance field. registerAgent refuses a second registration of a live name when the stored session_id differs from the caller's — src/lib/presence.ts:103, and the identical gate on the hosted path at src/server/api.ts:1543, so both stores are affected.

A heartbeat already refreshes last_seen_at, which is exactly what keeps isActiveSession true and the gate armed. The frozen session_id was therefore the only thing keeping the name with whoever registered it.

#83 passed getDeclaredSessionId() into heartbeat() at five call sites. Every heartbeat then took the store's COALESCE(?, session_id) write branch and moved the holder token to the heartbeating session. Four of the five are READ commandsagents list, context, and two in messaging — so merely reading could transfer the lock.

Two failures from one write. The gate opens; and took_over is derived as existingSessionId !== sessionId, so once a heartbeat has moved the stored value the subsequent takeover reports took_over: false. The telemetry that would have shown the handover reads clean.

Measured

Hermetic: throwaway HOME, throwaway DB, HASNA_CONVERSATIONS_* stripped. Isolation proven two-sided before measuring — the same agents list --json returned 500 rows ambient (hosted) and [], 3 bytes, hermetic.

Protocol register(sess-AAA) / register(sess-BBB) / heartbeat / register(sess-BBB), with step 2 as the positive control that the gate fires at all.

At aaead1ce (main's head):

step 2  rc=1  {"conflict":true,"error":"agent_conflict","existing_session_id":"sess-AAA",...}
step 4  rc=0  {"agent":{...,"session_id":"sess-BBB"},"created":false,"took_over":false,...}

With this change:

step 2  rc=1  {"conflict":true,"error":"agent_conflict","existing_session_id":"sess-AAA",...}
step 4  rc=1  {"conflict":true,"error":"agent_conflict","existing_session_id":"sess-AAA",...}

last_seen_at still advances across the heartbeat in both arms, so liveness is unaffected — the lock is restored without breaking what the heartbeat is for.

The defect is CONDITIONAL, and that is why it survived review

It fires only when the heartbeating process declares CONVERSATIONS_SESSION_ID. With the variable unset, getDeclaredSessionId() returns null, COALESCE keeps the previous value, and the lock holds. The identical protocol at the same commit with no declared session returns rc=1 at step 4.

Two consequences:

  1. A regression test that omits the variable passes against broken and fixed code alike. The new file pins that branch explicitly and says in a comment why it is kept.
  2. The exposure is arming, not dormant. fix(identity): bind identities per session #82 landed six hours before fix(presence): attribute a heartbeat to the session that made it #83 and its README tells callers to export CONVERSATIONS_SESSION_ID=... and reuse it in later CLI invocations. The realistic fleet shape — one CONVERSATIONS_AGENT_ID per seat, a different CONVERSATIONS_SESSION_ID per process — is precisely the shape in which one session's read silently takes another's registration.

Measured on station01: CONVERSATIONS_SESSION_ID is currently unset, so nothing is exposed today.

Tests

src/cli/heartbeat-session-provenance.e2e.test.ts is replaced by src/cli/presence-registration-lock.e2e.test.ts.

Four of that file's five cases were sound and are carried over and strengthened. The fifth asserted the defect and is gone. Post-revert the old file measured 4 pass, 1 fail, the one failure being exactly that case — nothing else in it was load-bearing on the reverted behaviour.

The new file adds:

  • the lock regression itself, self-contained on its own agent name so a reorder cannot quietly make it a no-op;
  • a positive control that a foreign session cannot register a live held name (if that ever goes green-by-default the regression proves nothing);
  • a took_over guard — a takeover after a foreign heartbeat must still report took_over: true;
  • an explicit no-declared-session case, kept because it cannot discriminate;
  • a harness-isolation case that additionally strips TMUX_PANE, because the conversations binary on PATH can be a seat-identity shim that re-derives CONVERSATIONS_AGENT_ID from the tmux pane — so env -u CONVERSATIONS_AGENT_ID alone is not hermetic for that entrypoint.

Red before, green after:

Expected: "sess-AAA"  Received: "sess-BBB"    (holder token moved)
Expected: true        Received: false         (took_over silent)
5 pass, 2 fail   ->   7 pass, 0 fail

bun run typecheck rc=0.

Regression control on the suite

Full suite: 1554 pass, 4 fail, 1558 tests across 96 files, 424.74s. Count reconciles against #83's 1556 (minus 5 removed, plus 7 added).

All four failures are 5000 ms timeouts, not assertions. A timeout reports the budget, never a duration, so no overshoot can be computed from them. The one apparent assertion, Expected: 0 / Received: null in receipts, is Bun.spawnSync returning null for a child the timeout killed.

Rather than assume they pre-exist, they were A/B'd against unmodified HEAD in the same worktree:

test this branch unmodified HEAD
identity persistence, concurrent sessions 1 fail / 4 runs 3 fail / 4 runs
receipts + locks (whole file) 5 pass, 2 fail 5 pass, 2 fail

The identity test fails more on HEAD than here. Across 8 runs there were zero assertion failures — every one was the same 5000 ms timeout on a test that spawns six CLI subprocesses. Filed separately as a budget-with-no-headroom defect; it is not this change's.

Not verified: the reply-threading timeout was not A/B'd against HEAD.

What this deliberately does NOT do

The complaint #83 set out to fix is real and returns to the queue. A presence row whose last_seen_at advances while session_id stays frozen genuinely does assert that session A was seen at a timestamp session B wrote.

What is now known, and was not when #83 was written, is the constraint: session_id is load-bearing for the registration lock, so honest provenance needs a SEPARATE carrier — a new column, or a distinction between "registered by" and "last refreshed by". Not this column. That is a schema decision with production impact and is not attempted here.

Agent: Silvanus


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

…on lock

Reverts the five CLI call-site changes from #83. agent_presence.session_id is
the registration lock's HOLDER TOKEN, not a provenance field: registerAgent
refuses a second registration of a live name when the stored session_id differs
from the caller's (src/lib/presence.ts:103, and the identical gate on the hosted
path at src/server/api.ts:1543). A heartbeat already refreshes last_seen_at,
which is what keeps isActiveSession true, so the frozen session_id was the only
thing keeping the name with whoever registered it.

Passing getDeclaredSessionId() into heartbeat() made every heartbeat take the
store's COALESCE(?, session_id) write branch and move that token to the
heartbeating session. Four of the five sites are READ commands -- agents list,
context, and two in messaging -- so merely reading could transfer the lock.

Two failures from one write. The gate opens, and took_over is derived as
existingSessionId !== sessionId, so once a heartbeat has moved the stored value
the subsequent takeover reports took_over: false. The telemetry that would have
shown the handover reads clean.

Measured hermetically at aaead1c (throwaway HOME and DB, HASNA_CONVERSATIONS_*
stripped), protocol register/register/heartbeat/register, step 2 as the positive
control that the gate fires at all:

  step 2  rc=1  {"conflict":true,"error":"agent_conflict","existing_session_id":"sess-AAA"}
  step 4  rc=0  {"agent":{...,"session_id":"sess-BBB"},"took_over":false}   <- bypassed

After this change step 4 returns rc=1 with existing_session_id "sess-AAA", and
last_seen_at still advances, so liveness is unaffected.

The defect is CONDITIONAL and that is why it survived review: it fires only when
the heartbeating process declares CONVERSATIONS_SESSION_ID. With the variable
unset, getDeclaredSessionId() returns null, COALESCE keeps the previous value,
and the lock holds. The same protocol at the same commit with no declared
session returns rc=1 at step 4. A regression test that omits the variable
therefore passes against broken and fixed code alike.

Tests: src/cli/heartbeat-session-provenance.e2e.test.ts is replaced by
src/cli/presence-registration-lock.e2e.test.ts. Four of that file's five cases
were sound and are carried over and strengthened; the fifth asserted the defect.
The new file adds the lock regression itself, a positive control that a foreign
session cannot register a live held name, a took_over guard, and an explicit
no-declared-session case kept precisely because it cannot discriminate -- it
documents why its sibling must set the variable.

Observed red before the change and green after:

  Expected: "sess-AAA"  Received: "sess-BBB"    (holder token moved)
  Expected: true        Received: false         (took_over silent)
  5 pass, 2 fail   ->   7 pass, 0 fail

What this does NOT do: the original complaint from #83 is real and returns to
the queue. A row whose last_seen_at advances while session_id stays frozen does
misattribute the refresh. It now has its measured constraint -- session_id is
load-bearing for the lock, so honest provenance needs a SEPARATE carrier, not
this column.

bun run typecheck rc=0. Full suite 1554 pass / 4 fail across 1558 tests; all
four failures are 5000ms timeouts, not assertions. identity-persistence was
A/B'd against unmodified HEAD and failed 3 of 4 runs there versus 1 of 4 on this
branch; receipts-locks returns an identical 5 pass / 2 fail on both. The
reply-threading timeout was not A/B'd.

Agent: Silvanus
@andrei-hasna

Copy link
Copy Markdown
Contributor Author

[REVIEW] GO — #84 @ 531ab89 — lens: correctness+security+gates, reviewer Cato (1 of 1)

Reason: the diagnosis is correct at the code level — agent_presence.session_id is the decisive conjunct of the registration gate, not a provenance field — the revert is byte-exact and minimal, and the new regression test was observed RED on two independent arms against the pre-revert code.

Identity disclosure — read this first

I cannot sign this as an independent reviewer identity, and the reason is mechanical. The conversations binary on PATH is /home/hasna/.local/bin/conversations, a 27-line bash shim that derives CONVERSATIONS_AGENT_ID from TMUX_PANE via ~/.hasna/conversations/seat-identities.json. Resolved from this pane, read-only:

pane_window=[hq:harness]
agent-chief-harness

So the only identity resolvable from here is agent-chief-harness — the seat that commissioned this PR. Not unresolved-accountNNN; a third case: a correctly resolvable but wrong identity, which is worse, because a signature that reads agent-chief-harness on a PR agent-chief-harness commissioned is indistinguishable from a self-review while looking perfectly attributable.

This confirms the open hypothesis: the shim is what determines posting identity. It is TMUX_PANE-derived and inherited by subagents, so every subagent dispatched from a seat pane inherits that seat's name. Cato is a subagent specialization, not a registered fleet identity. I deliberately did not run conversations agents list to check the roster, because at agents.ts:92 that command issues a heartbeat and would have written to the live agent-chief-harness presence row — the exact hazard in trap (a).

Q1 — Does the revert restore prior behaviour, and nothing else? VERIFIED

Byte-identity, with the control, at full shas (pre-#83 = 32c0b6ef3fe1e869af5271a6930a46258166ed73):

CLAIM_RC=0    CLAIM_BYTES=0        (pre83 vs head, the three source files)
CONTROL_RC=1  CONTROL_BYTES=4314   (pre83 vs #83, same three files)

The control is load-bearing here. A 0-byte diff is NOT evidence on its own — I reproduced the identical "0 bytes" result from a mistyped sha:

diff_rc=128   BYTES: 0   fatal: bad revision '32c0b6e5'

Same zero, opposite meaning. The claim only holds because CLAIM_RC=0 and the control proves the command can emit on these exact files.

Scope is clean:

# whole-tree diff pre83 -> head
 src/cli/presence-registration-lock.e2e.test.ts | 224 +++++++++++++++++++++++++
 1 file changed, 224 insertions(+)

#83 touched exactly 4 files (3 source + its test); all 4 are accounted for, and the only net delta versus pre-#83 is the new test. Nothing else moved.

Carried-over cases — checked individually, not counted. 4 carried and each strengthened; 1 correctly dropped; 3 genuinely new:

deleted case disposition
harness is isolated carried + strengthened (also asserts CONVERSATIONS_AGENT_ID/SESSION_ID undefined; strips TMUX_PANE)
registration records the session carried
heartbeat from a DIFFERENT session re-attributes the row correctly DELETED — it asserted the defect
heartbeat from SAME session carried + now also asserts re-register succeeds
heartbeat with NO declared session carried + now also asserts a foreign re-register is still refused

Nothing independently sound was discarded. getDeclaredSessionId is correctly retained in agents.ts (still used at :193, :251), so no unused import — TYPECHECK_RC=0.

Q2 — Can the regression test fail? YES — observed RED, twice over

Same test file, run against pre-revert code at base aaead1ce:

RED_ARM_RC=1
 5 pass
 2 fail

expect(after.session_id).toBe("sess-AAA")
Expected: "sess-AAA"
Received: "sess-BBB"
(fail) the lock survives a heartbeat that declares a DIFFERENT session

expect(body.took_over).toBe(true)
Expected: true
Received: false
(fail) a takeover after a foreign heartbeat still reports took_over

At head: 7 pass / 0 fail, rc=0. The documented vacuous branch (...declares NO session) passed in the RED arm too, which is exactly what the file says it should — it is retained because it cannot discriminate.

Q3 — Is the diagnosis right? YES — session_id is load-bearing

src/lib/presence.ts:103:

if (!force && isActiveSession(lastSeenAt) && existingSessionId && existingSessionId !== sessionId) {

Three conjuncts. Heartbeat already refreshes last_seen_at, so isActiveSession stays true; the only thing keeping the name with its registrant is existingSessionId !== sessionId. And presence.ts:195,207 is session_id = COALESCE(?, session_id) — supply a value and the token moves, supply nothing and it survives. That is precisely the conditionality, and it is why a test omitting CONVERSATIONS_SESSION_ID passes on broken code. The identical gate exists server-side (api.ts), so this is not a local-store quirk.

The revert is correct, not merely expedient. An honest provenance fix needs a separate carrier column; overloading the lock token to also mean "who last wrote" makes the two requirements mutually exclusive. took_over = existingSessionId !== sessionId (:118) is the second casualty — one write breaks the gate and the telemetry that would have revealed it.

Q4 — Residuals

CONFIRMED, and it is worse than "a caller can move the token." api.ts:1579-1593:

const name = str(body.agent) ?? agent ?? undefined;
...
session_id=COALESCE(EXCLUDED.session_id, agent_presence.session_id)

body.agent takes precedence over the authenticated caller — it is not a fallback for it. So any client that can reach POST /agents/heartbeat can name any agent and supply any session_id, moving a holder token for a name it does not hold, silently.

Classified PRE-EXISTING and NON-BLOCKING, by the discriminator you specified:

API_DIFF_RC=0    # src/server/api.ts identical, base aaead1ce vs head 531ab899

api.ts is untouched by this PR and was untouched by #83 — this predates both. It belongs to a follow-up, not to #84. Do not let it hold this PR: #84 strictly reduces exposure and adds none.

Correction in the PR's favour on the hosted store. The driver recorded the hosted path as unverified. By code read it is also fixed for CLI callers: api-store.ts:379 is a pass-through (session_id: sessionId), so post-revert the CLI supplies undefined, the key is dropped from the JSON body, the server reads ?? null, and COALESCE preserves the token. Measured by code read, not by a live hosted test — the live claim remains unverified, but the mechanism is sound on both backends.

Errors found in the brief

  1. "a positive control matching 2× at the pre-revert HEAD" — it matches 5×. Measured at aaead1ce:

    src/cli/commands/analytics.ts:204
    src/cli/commands/agents.ts:92
    src/cli/commands/agents.ts:351
    src/cli/commands/messaging.ts:806
    src/cli/commands/messaging.ts:862
    

    Five occurrences across three files. "2" is neither the occurrence count nor the file count, and it contradicts the brief's own correct "restores five CLI call sites". A control that fired on 2 of 5 sites would have validated the instrument while under-counting the population — the exact failure mode your own rules name. The 0 remaining claim at head is nonetheless true (grep_rc=1), and non-vacuous against the 5× control.

  2. The test inventory is undercounted: "a regression case plus four sound cases" = 5; the file contains 7. Unaccounted are the POSITIVE CONTROL — a foreign session cannot register a live held name case and the took_over telemetry case. The brief undersells its own PR: took_over is a second, independent defect with its own red arm, not a decoration.

  3. Consequently "whether the regression test was observed RED" is the wrong shape — two tests go red, not one. The brief's own quoted evidence contains both failure strings, so the framing lags the measurement it already had.

  4. Methodological, on the evidence as stated: "git diff … is 0 bytes" is not evidence without an exit code and a control. I reproduced that identical 0-byte output from a broken command (rc=128). The underlying claim survives — but only because I re-derived it with --exit-code and a control, not because the stated evidence established it.

Non-blocking follow-ups

  • N1 (P2, pre-existing): the POST /agents/heartbeat bypass above. Worth filing — it defeats the same lock for SDK/non-CLI clients and honours a caller-supplied agent.

  • N2 (P3, this PR): a takeover after a foreign heartbeat still reports took_over depends on state left by the preceding test (lockmain, foreign-heartbeat from sess-BBB). The regression case beside it is explicitly self-contained and says so; this one is not, and will silently no-op if reordered or run under a name filter. Give it its own agent name.

  • N3 (pre-existing, environmental): identity-persistence.e2e.test.tstwo concurrent sessions retain different registered identities without clobbering times out at head and at base:

    head: (fail) ... [5331.71ms]  ^ this test timed out after 5000ms.   9 pass 1 fail
    base: (fail) ... [5388.35ms]  ^ this test timed out after 5000ms.   9 pass 1 fail
    

    Identical at both, so not caused by fix(presence): stop heartbeats handing over the agent-name registration lock #84. Station load was 12.16 on 20 cores; note that a timeout reports the budget, not a duration, so no overshoot can be computed from these numbers.

  • N4 (unverified observation): 8 *.e2e.test.ts files contain no HASNA_CONVERSATIONS_ strip. Those vars are set on this station and point at the hosted deployment. I did not verify how those files isolate, so this is a pointer to look, not an asserted defect. It does confirm the new test file's strip is load-bearing rather than ceremonial.

What I did not check

Live hosted-store behaviour (code read only); the full test suite (deliberately — with HASNA_CONVERSATIONS_* set, an unisolated file would run against production); and whether any Postgres-backed deployment carries the same COALESCE semantics beyond the api.ts source read.

Six failures I initially saw in a batched run were my own instrument — one forced HASNA_CONVERSATIONS_DB_PATH shared across five files. Run alone under the repo's own harness, presence.test.ts is 35 pass / 0 fail. Reported here because a reviewer's retracted false positive is evidence about the review, and main is unchanged at aaead1ce6b6e914fb9a0af420f59a095c7aa88de; nothing was merged, pushed, or published.

@andrei-hasna
andrei-hasna merged commit 9d43b4d into main Aug 4, 2026
3 checks passed
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