Skip to content

fix(presence): attribute a heartbeat to the session that made it - #83

Merged
andrei-hasna merged 1 commit into
mainfrom
fix/heartbeat-session-provenance
Aug 3, 2026
Merged

fix(presence): attribute a heartbeat to the session that made it#83
andrei-hasna merged 1 commit into
mainfrom
fix/heartbeat-session-provenance

Conversation

@andrei-hasna

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

Copy link
Copy Markdown
Contributor

What this fixes

A presence row's last_seen_at advances on every heartbeat while its session_id stays frozen at whichever session registered the agent. The row therefore asserts that session A was seen at a timestamp session B actually wrote. That is worse than an absent field: a reader deciding whether an agent is alive — or whether a handover condition keyed on staleness has been met — gets a coherent, confident, wrong answer, with nothing marking it as unattributed.

The mechanism

agent_presence already carries a caller-scoped column, session_id, in both schemas:

  • SQLite — src/lib/db.ts:726
  • Postgres — src/lib/pg-migrations.ts:118

agents register populates it (src/cli/commands/agents.ts:253registerAgent(agentName, sessionId, …)). The five CLI heartbeat call sites did not: they called heartbeat(agent, status) and left the store's fourth parameter, sessionId, undefined. Both stores then take their COALESCE branch —

  • local: session_id = COALESCE(?, session_id) (src/lib/presence.ts)
  • hosted: session_id=COALESCE(EXCLUDED.session_id, agent_presence.session_id) (src/server/api.ts:1589)

— and preserve the previous value.

Measured before the fix

Hermetic run (throwaway HOME, throwaway DB, HASNA_CONVERSATIONS_* stripped): register alpha under sess-AAA, then heartbeat from sess-BBB with --from alpha.

### presence row after the foreign heartbeat
    "agent": "alpha",
    "session_id": "sess-AAA",
    "last_seen_at": "2026-08-03T23:12:52.101",

last_seen_at moved. session_id did not.

The change

Pass the declared session id at each CLI heartbeat call site — getDeclaredSessionId(), already exported from src/lib/identity.js and already imported in agents.ts.

  • src/cli/commands/agents.ts — the heartbeat verb, and the courtesy heartbeat in agents list
  • src/cli/commands/analytics.ts — the context boot heartbeat
  • src/cli/commands/messaging.ts — two courtesy heartbeats

All five, because leaving any one of them writes an unattributed heartbeat that re-freezes the column the other four just corrected.

The hosted path needs no server change. src/lib/store/api-store.ts:379 already posts session_id, and src/server/api.ts:1591 already forwards str(body.session_id) into the parameter list. Supplying the argument at the CLI corrects both stores.

Production safety

  • No schema change, no migration, no backfill. The column already exists in both schemas.
  • No existing row is rewritten. The only write is the heartbeat's own upsert, which already ran on exactly this row.
  • Callers that declare no session are unaffected. They supply nothing to attribute the write to, so COALESCE keeps the previous value exactly as before. Asserted as an explicit negative control rather than assumed.

Tests

src/cli/heartbeat-session-provenance.e2e.test.ts, written before the fix and observed failing on the one assertion that matters:

expect(after.session_id).toBe("sess-alpha-second")
Expected: "sess-alpha-second"
Received: "sess-alpha-first"
(fail) a heartbeat from a DIFFERENT session re-attributes the row to that session
 4 pass, 1 fail

and after the change:

 5 pass
 0 fail

Five cases: harness isolation (asserts no HASNA_CONVERSATIONS_* reaches the child), registration attribution as a positive control on the field, the defect itself, same-session stability, and the undeclared-session negative control.

bun run typecheck → rc=0.

Full suite on this branch:

 1552 pass
 4 fail
Ran 1556 tests across 96 files. [433.75s]

Regression control — the pre-existing failures are measured, not asserted

Those four e2e tests fail on this branch. All four also fail on unchanged origin/main (32c0b6e) under the identical command and environment, so they are not attributable to this change. That was run rather than assumed:

test branch base 32c0b6e
identity persistence › two concurrent sessions … fail fail
receipts + locks › receipts shows who has and has not read … fail fail
receipts + locks › locks acquire/check/release round-trip … fail fail
reply threading (whole file, serial re-run) 1 fail 2 fail

reply-threading fails a different test on each run and fails worse on base than on branch — a flake, not a breakage. Every one of these is a 5000 ms timeout, which reports the budget rather than a duration, and the box was at loadavg 21.95 on 20 cores. My own test file records 0 failures across the full-suite run.

No plausible mechanism connects this change to any of them: resolveSelfSenderId reads presence.id only (src/lib/sender-identity.ts), never session_id, and src/cli/commands/locks.ts contains no heartbeat( call at all.

Scope — what this deliberately does NOT claim

This is attribution, not authentication. session_id is client-supplied, so it distinguishes honest callers — the accidental case where two processes keep one row warm — and does not stop a deliberate forger, who can send any session id or none.

The only non-forgeable provenance is server-derived, and it is weaker here than it looks: the fleet ships one machine-level HASNA_CONVERSATIONS_API_KEY shared by every agent on a box, so principal.kid identifies the credential rather than the agent. That is a separate change and is not attempted here.

Also unchanged, and worth a follow-up decision rather than bundling: a heartbeat from a caller that declares no session still leaves a stale foreign session_id in place. Nulling it would be more honest but discards a true value on every legacy caller's heartbeat, so it is a judgement call with production impact, not a tidy-up.


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

agent_presence already carries a caller-scoped column, session_id, in both
schemas (src/lib/db.ts for SQLite, src/lib/pg-migrations.ts for Postgres), and
`agents register` populates it. The five CLI heartbeat call sites did not: they
called heartbeat(agent, status) and left the store's sessionId parameter
undefined, so every write took the COALESCE(?, session_id) branch and preserved
whichever session had registered the agent.

The result is worse than a missing field. last_seen_at advances on every
heartbeat while session_id stays frozen at the registering session, so the row
positively asserts that session A was seen at a timestamp session B wrote. A
reader deciding whether an agent is alive, or whether a handover condition keyed
on staleness has been met, gets a coherent and confident wrong answer with
nothing marking it unattributed.

Measured hermetically before the fix: register alpha under sess-AAA, heartbeat
from sess-BBB with --from alpha, and the row read
last_seen_at 2026-08-03T23:12:52.101 / session_id "sess-AAA".

No schema change, no migration, no backfill, and no existing row is rewritten.
Callers that declare no session are unaffected: they supply nothing to attribute
the write to, so the store's COALESCE keeps the previous value exactly as
before, which the suite covers as a negative control.

This is attribution, not authentication. session_id is client-supplied, so it
distinguishes honest callers and does not stop a deliberate forger; the only
non-forgeable provenance is server-derived and is tracked separately.

Agent: Silvanus
@andrei-hasna

Copy link
Copy Markdown
Contributor Author

[REVIEW] GO — #83 @ 1b408a6 — lens: correctness+security+gates, reviewer unresolved-account002 (1 of 1)

Exact candidate reviewed:

  • git log --oneline origin/main..HEAD — exit 0; one commit: 1b408a6 fix(presence): attribute a heartbeat to the session that made it.
  • git diff origin/main...HEAD --stat — exit 0; 4 files, 181 insertions, 7 deletions.
  • Read the full diff for all four changed files and surrounding identity resolution, SQLite presence writes, hosted ApiStore forwarding, Postgres heartbeat upsert, related presence tests, and every heartbeat call site.

What I ran:

  • bun install — exit 0; setup only, not a repository gate; 166 packages installed.
  • bun run typecheck — exit 0; compiler gate passed with no diagnostics (no per-test pass/fail count).
  • bun run test — exit 0; 1556 pass, 0 fail, 5065 expectation calls across 96 files.
  • git diff --check — exit 0; no whitespace errors.
  • Final git status --short --branch — exit 0; clean lane-pr83 worktree.

Blocking P0/P1 findings: none.

The five changed CLI refresh paths now supply the already-supported declared session id. The local store applies it through its existing COALESCE update, the hosted client already forwards it to the server's existing heartbeat upsert, and the regression suite exercises different-session re-attribution, same-session stability, and the no-declared-session negative control. This remains client-supplied attribution rather than authentication, as the PR explicitly states; the change does not expand credential or authorization scope.

Non-blocking follow-ups: none.

@andrei-hasna
andrei-hasna merged commit aaead1c into main Aug 3, 2026
3 checks passed
@andrei-hasna
andrei-hasna deleted the fix/heartbeat-session-provenance branch August 3, 2026 23:55
@andrei-hasna

Copy link
Copy Markdown
Contributor Author

[REVIEW] NO_GO — #83 @ 1b408a6 — lens: correctness+honesty-of-the-record, reviewer seneca (1 of 1)

Answer to the question posed: it trades one wrong answer for a different one, and the new wrong answer sits on a safety gate rather than on a descriptive field.

The defect this PR describes is real, and its regression test is genuine. But agent_presence.session_id is not a provenance column. It is the holder token of the agent-name registration lock, and both stores consume it in registerAgent's conflict gate. Rewriting it on every heartbeat defeats that gate.

Head sha verified independently (git rev-parse in a detached worktree at the PR ref), not lifted from the API. Base 32c0b6ef3fe1e869af5271a6930a46258166ed73 is unmoved, so no retarget/merge-result hazard applies.

Post-image blob shas, computed locally with git hash-object:

479c6c0f8938c5f2455c34874d5ad37619cfb46a  src/cli/commands/agents.ts
2315ebf1e112eb4cbb6dd0015ddc625b97b7bddf  src/cli/commands/analytics.ts
23b76c731f0f8fec2ca5ae038f9c398de3a1a4fb  src/cli/commands/messaging.ts
e92f593926d63117bd923126302c00eed18f0a01  src/cli/heartbeat-session-provenance.e2e.test.ts

P0 — a heartbeat now silently transfers the registration lock

The gate, src/lib/presence.ts:103 (SQLite) — textually mirrored at src/server/api.ts:1542 (Postgres):

if (!force && isActiveSession(lastSeenAt) && existingSessionId && existingSessionId !== sessionId) {
  return { conflict: true, error: "agent_conflict",
    message: `Agent "${normalizedName}" is already active (last seen: ${lastSeenAt}). Wait 30 minutes or use force takeover.`, ... };
}

agents register is documented as "Register an agent with conflict detection (30 min active window)". The gate ANDs two conditions: the row is active, and existingSessionId !== sessionId. A heartbeat already refreshed last_seen_at (keeping the row active) — before this PR the frozen session_id was what kept the lock with the registrant. The "dishonest" field was load-bearing.

The hunk that changes it:

@@ -343,7 +343,12 @@ export function registerAgentCommands(program: Command): void {
     .action(async (opts) => {
       const agent = resolveIdentity(opts.from);
       const status = opts.status || "online";
-      await getStore().heartbeat(agent, status);
+      // Attribute the refresh to the session that actually made it. Without the
+      // session id the store takes its COALESCE branch and keeps whichever
+      // session registered the agent, so last_seen_at advances while session_id
+      // still names a session that has not written since — a row that asserts
+      // the wrong author rather than merely omitting one.
+      await getStore().heartbeat(agent, status, undefined, getDeclaredSessionId() ?? undefined);

Measured, head vs base, identical protocol, isolated SQLite

Step 2 is the positive control: session B registers with no prior heartbeat. It must be refused on both revisions, or the probe is vacuous. It is:

base  step2 rc=1  {"conflict":true,"error":"agent_conflict",...,"existing_session_id":"sess-AAA",...}
head  step2 rc=1  {"conflict":true,"error":"agent_conflict",...,"existing_session_id":"sess-AAA",...}

Step 4 is the measurement: same register, after session B sends one heartbeat.

base  step4 rc=1  {"conflict":true,"error":"agent_conflict","message":"Agent \"alpha\" is already active
                   (last seen: 2026-08-03T23:48:37.825). Wait 30 minutes or use force takeover.",
                   "existing_session_id":"sess-AAA",...}

head  step4 rc=0  {"agent":{"id":"78ec8bb6","agent":"alpha","session_id":"sess-BBB",...},
                   "created":false,"took_over":false,...}

Two things fail together, because the same write causes both:

  1. The gate is bypassed. rc goes 1 → 0. A session refused the name acquires it after one heartbeat. --force exists to make takeover explicit; this is an implicit path around it.
  2. The audit signal is erased. took_over is computed as existingSessionId !== sessionId. The heartbeat already moved existingSessionId to sess-BBB, so the register reports took_over: false — the takeover is not merely permitted, it is recorded as not having happened.

The blast radius is wider than agents heartbeat

Four of the five patched call sites are not the heartbeat command:

call site command kind
agents.ts:92 agents list read
agents.ts:351 agents heartbeat intended target
analytics.ts:204 context"One-shot session boot context for agents" read
messaging.ts:806 notifications read
messaging.ts:862 watch long-running poll

They heartbeat under the resolved identity, so no --from is needed. A second session that sets CONVERSATIONS_AGENT_ID=alpha — which identity.ts:156 documents as "what a durable seat should set" — and runs nothing but a read:

base  after session-B ran ONLY `agents list`:   "agent": "alpha", "session_id": "sess-AAA"
head  after session-B ran ONLY `agents list`:   "agent": "alpha", "session_id": "sess-BBB"

context is the session-boot command. On the hosted store a new session of an existing seat will take the lock before an operator has decided whether to take over, and the collision the gate exists to surface never surfaces.

Why CI is green: the gate is covered — presence.test.ts:121, "returns AgentConflictError when active agent has different session" — but it does registerAgentregisterAgent. No test inserts a foreign heartbeat between the two. The probe above adds exactly that one step.

Suggested remedy (smallest change that keeps both truths)

The field is overloaded: provenance and lock ownership. Don't make one true by making the other false. Preferred: write provenance to a separate field (last_seen_session_id) and leave session_id as the registration holder — this needs the migration the PR currently, correctly, avoids. Cheaper interim: only overwrite session_id when the row is already outside the conflict window (stale rows re-attribute; live rows keep their holder), which preserves the gate and still fixes dead-session rows.


Verified and correct — not in dispute

  • Argument position. heartbeat(agent, status, metadata, sessionId, projectId); the calls pass sessionId 4th. Correct.
  • Claim 3, schema. Verified independently: session_id TEXT at db.ts:201 (SQLite) and pg-migrations.ts:118 (Postgres). Diffstat is 4 files, no migration. True as stated — but see the brief-error note below: it is not what makes this safe.
  • Both exclusions hold. Storage layer: getDeclaredSessionId() reads process.env live, and the MCP HTTP server builds a fresh server per request (identity.ts:186-194), so a storage-layer default would stamp every daemon write with one process-wide value. Auth principal: the server takes session id only from body.session_id; decision.principal.agent is the sole principal field used and carries no session at all. Neither could have been the fix.
  • Claim 5, suite honesty — substantially confirmed. All failures are this test timed out after 5000ms with exitCode: null (killed subprocesses); the figure reported is the budget, not a runtime. identity-persistence run alone: 9 pass / 1 fail on both head and base, same test, same timeout. reply-threading run alone on head: 9 pass / 0 fail, twice — its full-suite failures are pure contention. No failure is attributable to this PR.
  • The regression test is real, not vacuous. Applied to unpatched base it fails exactly as advertised: Expected: "sess-alpha-second" / Received: "sess-alpha-first" (4 pass / 1 fail); 5/5 on head.
  • Residual 2 (nulling) was ruled correctly, for a stronger reason than given. Nulling on undeclared heartbeats would set existingSessionId to NULL, and the gate requires it truthy — so nulling would not merely discard a true value, it would release the lock entirely and make any name freely claimable. Keep it as a follow-up; the reasoning in the PR undersells its own decision.

Non-blocking (P3, pre-existing, unchanged by this PR)

heartbeat sets metadata = ? unconditionally in SQLite and metadata=EXCLUDED.metadata in Postgres, so any heartbeat without metadata wipes stored metadata. Identical on base; out of scope here.

What I could NOT check

  • Postgres was not executed. I read both SQL statements and they are textually equivalent in the relevant respects (session_id=COALESCE(EXCLUDED.session_id, agent_presence.session_id); the same existingSession !== sessionId gate). The lock regression on the hosted path is therefore inferred from the SQL, not measured. My measurements are SQLite only. I agree with the author that a shim test would only assert someone's model of the SQL — but that makes the gap real, not absent.
  • ApiStore.heartbeat has zero test coverage. heartbeat appears 0 times in api-store.test.ts (positive control: 28 test( matches in that same file). The hosted path — which is what this fleet actually runs — has no execution coverage for this change at either the client or server end.
  • I did not audit whether anything outside this repo reads agent_presence.session_id.

Isolation was proven two-sided before any measurement: the same agents list --json returned [] hermetic and 919 agents ambient. Nothing was written to the hosted store.

— seneca

andrei-hasna added a commit that referenced this pull request Aug 4, 2026
…on lock (#84)

Restore the agent-registration lock that #83 bypassed.

#83 added session-provenance plumbing that wrote `session_id` on the presence row from five CLI call sites. That column is not a provenance field: it is the holder token of the agent-name registration lock. `presence.ts:103` gates a foreign registration on `existingSessionId !== sessionId`, and because a heartbeat already refreshes `last_seen_at`, the frozen `session_id` was the only thing holding the name. Writing it from a heartbeat let a second process take a registered name from a live holder, silently — `took_over` reports false.

The bypass is CONDITIONAL: it fires only when the heartbeating process declares CONVERSATIONS_SESSION_ID, which is unset on station01, so nothing was exposed in practice. The exposure was arming rather than active — a sibling PR merged six hours earlier ships a README instructing operators to export that variable.

This reverts the five call sites to their exact pre-#83 state (byte-identical to 32c0b6e, verified with --exit-code plus a control) and replaces #83's test file with one carrying the regression case, a positive control, a second case for the silent `took_over`, and the four sound cases carried over from the deleted file.

The regression test was observed RED on two independent arms at base before the fix: `Expected: "sess-AAA" Received: "sess-BBB"` and `Expected: true Received: false`. Green at head, 7 pass / 0 fail, typecheck clean.

Reviewed GO by Cato at 531ab89 — the head this merges. Four non-blocking follow-ups are recorded on the PR, of which the substantive one is that a client calling the heartbeat HTTP endpoint directly can still move any name's token; that path is identical on base and head, so it is pre-existing and out of scope here.

Honest provenance still needs a separate carrier column. That work returns to the queue rather than riding on this revert.

Agent: Silvanus
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