Skip to content

feat(protocol): Symposium Phase 1 — types, event-store, tests - #446

Open
dimakis wants to merge 17 commits into
mainfrom
session/2026-08-07-7071aece6f22
Open

feat(protocol): Symposium Phase 1 — types, event-store, tests#446
dimakis wants to merge 17 commits into
mainfrom
session/2026-08-07-7071aece6f22

Conversation

@dimakis

@dimakis dimakis commented Aug 7, 2026

Copy link
Copy Markdown
Owner

Summary

  • New types: SessionType, TurnMode, InterceptMode, SeatConfig, TurnRules, SymposiumConfig
  • Event-store migration: session_type + symposium_config columns
  • upsertSession wiring for symposium config
  • 14 comprehensive tests covering type shapes and config composition

Test Plan

  • All protocol package tests pass (257/257)
  • Types importable from @mitzo/protocol
  • Migration runs cleanly on existing databases
  • Symposium config serializes/deserializes correctly

🤖 Generated with Claude Code

dimakis and others added 16 commits July 25, 2026 13:09
handleReconnect no longer does reattach/rekey/zombie cleanup — that's
all handled by handleSendV2 on the first user message. Reconnect now
only does: watch + cursor replay + suspend resume + boot context.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Periodic sync (5s timer retrying missed events) is redundant now that
reconnect replays via EventStore cursor on welcome. Removes setEventStore,
startPeriodicSync, stopPeriodicSync, EventStoreAdapter interface, and
all associated tests and wiring.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Reconnect POST no longer defers _connected — the client marks connected
immediately on welcome. handleSendV2 handles ownership on first message,
and replayed events arrive via SSE regardless of POST outcome.

Removes doReconnectPost, scheduleReconnect, reconnectTimer, and
reconnectDelayMs. Replaces 12 deferred/stale/failure tests with 4
fire-and-forget tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Reconnect is REST-only — keep the schema export for the REST handler
but exclude it from IncomingWsMessageV2. The WS dispatcher already
ignores it with a comment explaining why.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…tion

Stale session test now asserts remove is NOT called (deferred to handleSendV2).
Suspend resume test clears reattachChat mock to avoid bleed from prior tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add missing setSessionState mock to routes and suspend-routes tests.
Remove reconnect from WS union test since P3 moved it to REST-only.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
P3 removed ReconnectMessage from the WS union (reconnect is now
REST-only), but the switch case was left behind causing a type error.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
… crashes

Without this try/catch, an unhandled error from the transport layer
kills the server, losing all in-memory state and triggering replay
storms on client reconnect.

Cherry-picked from #396 (now closed as superseded by P3).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…nt cursor race

- Restore ReconnectMessage in WS union and handler (WS clients still
  send reconnect over WS until P4 removes WS transport)
- Document why cursor race between fire-and-forget reconnect POST
  and handleSendV2 is benign (single-threaded + client seq dedup)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Add warn log for failed POSTs (was silent catch)
- Retry reconnect POST on next EventSource reconnect if it fails
- Flush pending sends AFTER reconnect POST (ordering guarantee)
- Reattach detached sessions on reconnect (was deferred to send)
- Fix comment to lead with client-side seq dedup, not event loop
- Add test for SSE event delivery after reconnect POST failure

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Check res.ok in doPost (RED: HTTP 500 was treated as success)
- Clear _pendingReconnectSessions on disconnect (YELLOW: stale retry)
- Filter pending reconnect sessions against seqBySession (YELLOW: cleared sessions retried)
- Reattach detached sessions when owner connection is gone (YELLOW: device restart gap)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Refresh lastSeq from current seqBySession on pending retry (YELLOW: stale seq causes unnecessary replay)
- Guard reconnect POST callback against stale connectionId (YELLOW: double-flush on rapid reconnect)
- Use getOwnerConnection() helper instead of .split(':')[0] (BLUE: consistency)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Defer _connected until reconnect POST succeeds (YELLOW: prevents
  sends from bypassing queue during in-flight window)
- Don't flush pending sends on POST failure (YELLOW: server hasn't
  set up cursor/replay, sends stay queued for next attempt)
- Add test for send queuing during reconnect POST in-flight (YELLOW)
- Fix 4 stale "periodic sync" comments (BLUE: removed in P3)
- Document cursor-at-0 bandwidth trade-off in handleSendV2 (YELLOW)
- Document transport/clientId mismatch window on reattach (YELLOW)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Throw on doPost when connectionId is null (YELLOW: silent success)
- Add P4 TODO for cursor reset unification (YELLOW: dedup assumption)
- Check EventStore state before reattaching on reconnect (YELLOW: zombie)
- Extract getReconnectSessions() for readability (BLUE: dense logic)
- Remove roadmap reference from WS reconnect comment (BLUE: stale)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

@dimakis dimakis left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Centaur Review

Found 7 issue(s) (4 warning).

packages/client/src/sse-connection.ts

Solid Phase 1 PR combining symposium types with a significant transport simplification (periodic sync removal, reconnect POST refactoring). The symposium additions are clean and complete. The main risk is the SSE reconnect refactoring: the staleness guard lost the ES-identity check (regression), and several edge-case tests were removed without replacement.

  • 🟡 unsafe_assumptions (L219): When the reconnect POST fails, _pendingReconnectSessions retains the sessions for retry on the next welcome. However, checkAndReconnect() (line 172) does NOT clear _pendingReconnectSessions — it only sets _connected = false and creates a new EventSource. This means getReconnectSessions() will use the stale pending list on the next welcome, which is the intended design. But if an onerror fires (EventSource auto-reconnect, line 267), _connected is set to false but _pendingReconnectSessions is also preserved. This is correct for the retry intent, but if the server-side session has been cleaned up between failures, the client will keep retrying a reconnect POST for a session that no longer exists — there's no TTL or attempt limit on _pendingReconnectSessions. Consider adding a retry cap or TTL to prevent infinite reconnect POST attempts for dead sessions. [fixable]
  • 🟡 regressions (L226): The old doReconnectPost had a staleness guard that checked BOTH this.es !== welcomeEs (EventSource instance identity) AND this._connectionId !== welcomeConnectionId. The new inline handler only checks this._connectionId !== postConnectionId. This drops the ES-identity guard. If checkAndReconnect() is called while a reconnect POST is in-flight, it closes the old ES and creates a new one, but _connectionId doesn't change until the new welcome arrives. During that window, if the stale POST resolves, the connectionId check alone won't catch it — _connectionId is still the old value because no new welcome has arrived yet. The old code would have bailed via the es !== welcomeEs check. This could cause _connected = true and flushPendingSends() to fire on a stale EventSource. [fixable]

packages/client/src/__tests__/sse-connection.test.ts

Solid Phase 1 PR combining symposium types with a significant transport simplification (periodic sync removal, reconnect POST refactoring). The symposium additions are clean and complete. The main risk is the SSE reconnect refactoring: the staleness guard lost the ES-identity check (regression), and several edge-case tests were removed without replacement.

  • 🟡 missing_tests: The PR removes tests for several important edge cases that were previously covered: (1) bails out if disconnect() called during in-flight reconnect POST — the new code clears _pendingReconnectSessions on disconnect, but there's no test that the in-flight POST's .then() callback is a no-op after disconnect. (2) ignores stale reconnect POST when a newer welcome arrives — the new code uses connectionId-based staleness, but the only test for this (marks connected after reconnect POST succeeds) doesn't test the race with a second welcome. (3) dispatches SSE events to listener while reconnect POST is in-flight — removed entirely with no replacement. SSE event delivery during the reconnect window is a critical path. [fixable]

packages/harness/src/connection-registry.ts

Solid Phase 1 PR combining symposium types with a significant transport simplification (periodic sync removal, reconnect POST refactoring). The symposium additions are clean and complete. The main risk is the SSE reconnect refactoring: the staleness guard lost the ES-identity check (regression), and several edge-case tests were removed without replacement.

  • 🟡 regressions: Removing periodic sync entirely means there is no server-side mechanism to retry delivery of events that failed during broadcast(). The old code would retry via periodic sync every 5s. The new design relies entirely on client-initiated reconnect (EventSource auto-reconnect → welcome → replay from EventStore). This is a deliberate architectural change documented in comments, but it means that a transient broadcast() failure (e.g., WebSocket buffer full) on a still-connected client will result in a permanently missed event until the client disconnects and reconnects. The comment at line 133 says 'reconnect replay will cover the gap' but that only happens on reconnect, not on transient send failures where the connection stays alive.

packages/protocol/__tests__/event-store.test.ts

Solid Phase 1 PR combining symposium types with a significant transport simplification (periodic sync removal, reconnect POST refactoring). The symposium additions are clean and complete. The main risk is the SSE reconnect refactoring: the staleness guard lost the ES-identity check (regression), and several edge-case tests were removed without replacement.

  • 🔵 style (L897): The test 'events table supports seat_id in payload' is misleading — seatId is just a JSON field inside the payload TEXT column, not a database column. The events table stores payload as a JSON string, so any key can be stored. This test doesn't validate any schema change; it only confirms that JSON round-trips correctly, which is already covered by existing event append/read tests. Consider removing or renaming to clarify it tests payload serialization of symposium-specific fields. [fixable]

server/ws-handler-v2.ts

Solid Phase 1 PR combining symposium types with a significant transport simplification (periodic sync removal, reconnect POST refactoring). The symposium additions are clean and complete. The main risk is the SSE reconnect refactoring: the staleness guard lost the ES-identity check (regression), and several edge-case tests were removed without replacement.

  • 🔵 style (L585): The 11-line TODO comment block (lines 585–595) explaining the cursor/seq-dedup trade-off is excessive inline documentation. The key insight ('client-side seq dedup handles duplicates between watch and reconnect POST') could be a 2-line comment. The detailed analysis of event loop ordering and HTTP request interleaving belongs in a design doc or commit message, not in the source code where it will rot. [fixable]

packages/protocol/__tests__/types.test.ts

Solid Phase 1 PR combining symposium types with a significant transport simplification (periodic sync removal, reconnect POST refactoring). The symposium additions are clean and complete. The main risk is the SSE reconnect refactoring: the staleness guard lost the ES-identity check (regression), and several edge-case tests were removed without replacement.

  • 🔵 bugs (L215): The symposium type tests (lines 215–319) only verify TypeScript type assignability at compile time — they don't validate any runtime behavior, Zod schemas, or serialization. For example, SessionType[] = ['chat', 'symposium'] proves the type accepts these literals, but doesn't verify exhaustiveness (a third value added to the union wouldn't fail this test). These tests provide marginal value over the TypeScript compiler itself. Consider adding Zod schema validation tests if runtime validation is planned.

// since the original failure, avoiding unnecessary replay.
const sessions = this.getReconnectSessions();
if (sessions) {
this._pendingReconnectSessions = sessions;

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 unsafe_assumptions: When the reconnect POST fails, _pendingReconnectSessions retains the sessions for retry on the next welcome. However, checkAndReconnect() (line 172) does NOT clear _pendingReconnectSessions — it only sets _connected = false and creates a new EventSource. This means getReconnectSessions() will use the stale pending list on the next welcome, which is the intended design. But if an onerror fires (EventSource auto-reconnect, line 267), _connected is set to false but _pendingReconnectSessions is also preserved. This is correct for the retry intent, but if the server-side session has been cleaned up between failures, the client will keep retrying a reconnect POST for a session that no longer exists — there's no TTL or attempt limit on _pendingReconnectSessions. Consider adding a retry cap or TTL to prevent infinite reconnect POST attempts for dead sessions. [fixable]

const postConnectionId = this._connectionId;
// Don't mark connected until POST succeeds — prevents send() from
// bypassing the pending queue and arriving before cursor setup.
this.doPost('reconnect', { type: 'reconnect', sessions }).then(

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 regressions: The old doReconnectPost had a staleness guard that checked BOTH this.es !== welcomeEs (EventSource instance identity) AND this._connectionId !== welcomeConnectionId. The new inline handler only checks this._connectionId !== postConnectionId. This drops the ES-identity guard. If checkAndReconnect() is called while a reconnect POST is in-flight, it closes the old ES and creates a new one, but _connectionId doesn't change until the new welcome arrives. During that window, if the stale POST resolves, the connectionId check alone won't catch it — _connectionId is still the old value because no new welcome has arrived yet. The old code would have bailed via the es !== welcomeEs check. This could cause _connected = true and flushPendingSends() to fire on a stale EventSource. [fixable]

expect(store.getSession('chat-2')!.symposiumConfig).toBeNull();
});

it('events table supports seat_id in payload', () => {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 style: The test 'events table supports seat_id in payload' is misleading — seatId is just a JSON field inside the payload TEXT column, not a database column. The events table stores payload as a JSON string, so any key can be stored. This test doesn't validate any schema change; it only confirms that JSON round-trips correctly, which is already covered by existing event append/read tests. Consider removing or renaming to clarify it tests payload serialization of symposium-specific fields. [fixable]

Comment thread server/ws-handler-v2.ts
}
applySkillPolicy(activeClientId);
ctx.connRegistry.watch(connectionId, sessionId);
// No resetCursor here — handleReconnect (fire-and-forget POST) sets

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 style: The 11-line TODO comment block (lines 585–595) explaining the cursor/seq-dedup trade-off is excessive inline documentation. The key insight ('client-side seq dedup handles duplicates between watch and reconnect POST') could be a 2-line comment. The detailed analysis of event loop ordering and HTTP request interleaving belongs in a design doc or commit message, not in the source code where it will rot. [fixable]

expect(meta.promptCount).toBe(3);
expect(meta.totalCostUsd).toBe(0.01);
});

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 bugs: The symposium type tests (lines 215–319) only verify TypeScript type assignability at compile time — they don't validate any runtime behavior, Zod schemas, or serialization. For example, SessionType[] = ['chat', 'symposium'] proves the type accepts these literals, but doesn't verify exhaustiveness (a third value added to the union wouldn't fail this test). These tests provide marginal value over the TypeScript compiler itself. Consider adding Zod schema validation tests if runtime validation is planned.

@dimakis dimakis left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Centaur Review

Found 6 issue(s) (3 warning).

packages/protocol/src/event-store.ts

Well-structured PR that cleanly adds Symposium Phase 1 types/migration and simplifies reconnect by removing periodic sync and deferring ownership to send/interrupt handlers. The main concern is an unsafe cast of session_type from the database; the reconnect simplification is sound with proper stale-callback guards.

  • 🟡 unsafe_assumptions (L812): Unsafe cast of row.session_type to SessionMeta['sessionType'] without validation. If the database contains a value other than 'chat' or 'symposium' (e.g. from a manual edit or future migration rollback), the cast silently passes through an invalid string. Consider validating the value: row.session_type === 'symposium' ? 'symposium' : 'chat'. [fixable]

server/chat.ts

Well-structured PR that cleanly adds Symposium Phase 1 types/migration and simplifies reconnect by removing periodic sync and deferring ownership to send/interrupt handlers. The main concern is an unsafe cast of session_type from the database; the reconnect simplification is sound with proper stale-callback guards.

  • 🔵 missing_tests (L1273): The new try/catch around queryInstance.interrupt() has no dedicated test. Adding a test where interrupt() rejects (e.g. ProcessTransport 'not ready for writing') would verify the function continues to queue the message and returns true despite the error. [fixable]

server/ws-handler-v2.ts

Well-structured PR that cleanly adds Symposium Phase 1 types/migration and simplifies reconnect by removing periodic sync and deferring ownership to send/interrupt handlers. The main concern is an unsafe cast of session_type from the database; the reconnect simplification is sound with proper stale-callback guards.

  • 🟡 unsafe_assumptions (L584): After watch() on handleSendV2's running-session path, cursor starts at 0 (default) until the reconnect POST arrives and calls resetCursor. The inline comment acknowledges this relies on client-side seq dedup for correctness. While the TODO(P4) notes this, the window is real: if the server broadcasts a high-seq event between watch() and resetCursor(), the client receives it (fine), but a concurrent reconnect replay could also deliver the same events. The comment says 'bandwidth trade-off, not correctness' but duplicate events could confuse non-idempotent client logic (e.g. incrementing counters). Verify that all client message handlers are truly idempotent under seq dedup.
  • 🟡 regressions (L266): Reconnect now reattaches detached sessions (refreshing transport) but no longer rekeys the clientId. The inline comment (lines 259-265) documents this is safe because 'event delivery uses the transport (refreshed)'. However, between reattach and the first send, getOwnerConnection() returns the stale connectionId. If another connection attempts a send/interrupt for the same session during this window, the ownership check sees the stale owner as 'gone' (not in connRegistry) and performs a takeover — which is correct behavior but may trigger spurious takeover logs. Not a bug, but worth being aware of in production log analysis.

packages/client/src/sse-connection.ts

Well-structured PR that cleanly adds Symposium Phase 1 types/migration and simplifies reconnect by removing periodic sync and deferring ownership to send/interrupt handlers. The main concern is an unsafe cast of session_type from the database; the reconnect simplification is sound with proper stale-callback guards.

  • 🔵 style (L226): The reconnect POST .then(successCb, failureCb) form is harder to read than async/await with try/catch, especially since the success handler is 5 lines and failure handler is 4 lines. However, since the welcome event handler is synchronous and the POST is intentionally fire-and-forget, the .then() form avoids making the handler async. This is acceptable — just noting the trade-off.

packages/protocol/__tests__/event-store.test.ts

Well-structured PR that cleanly adds Symposium Phase 1 types/migration and simplifies reconnect by removing periodic sync and deferring ownership to send/interrupt handlers. The main concern is an unsafe cast of session_type from the database; the reconnect simplification is sound with proper stale-callback guards.

  • 🔵 missing_tests (L901): The test 'events table supports seat_id in payload' only verifies that seatId round-trips through the JSON payload column. It doesn't test that the session associated with 'symp-4' was auto-created (upsertSession wasn't called). This works because append() auto-creates session rows, but makes the test implicitly dependent on that behavior. Consider calling store.upsertSession({ sessionId: 'symp-4', sessionType: 'symposium' }) first for clarity. [fixable]

lastStateChange: row.last_state_change ?? null,
agentName: row.agent_name ?? null,
bootContext: row.boot_context ?? null,
sessionType: (row.session_type as SessionMeta['sessionType']) ?? 'chat',

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 unsafe_assumptions: Unsafe cast of row.session_type to SessionMeta['sessionType'] without validation. If the database contains a value other than 'chat' or 'symposium' (e.g. from a manual edit or future migration rollback), the cast silently passes through an invalid string. Consider validating the value: row.session_type === 'symposium' ? 'symposium' : 'chat'. [fixable]

Comment thread server/chat.ts
await Promise.allSettled(stops);
}
await session.queryInstance.interrupt();
try {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 missing_tests: The new try/catch around queryInstance.interrupt() has no dedicated test. Adding a test where interrupt() rejects (e.g. ProcessTransport 'not ready for writing') would verify the function continues to queue the message and returns true despite the error. [fixable]

Comment thread server/ws-handler-v2.ts
@@ -576,6 +582,17 @@ export function handleSendV2(
}
applySkillPolicy(activeClientId);
ctx.connRegistry.watch(connectionId, sessionId);

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 unsafe_assumptions: After watch() on handleSendV2's running-session path, cursor starts at 0 (default) until the reconnect POST arrives and calls resetCursor. The inline comment acknowledges this relies on client-side seq dedup for correctness. While the TODO(P4) notes this, the window is real: if the server broadcasts a high-seq event between watch() and resetCursor(), the client receives it (fine), but a concurrent reconnect replay could also deliver the same events. The comment says 'bandwidth trade-off, not correctness' but duplicate events could confuse non-idempotent client logic (e.g. incrementing counters). Verify that all client message handlers are truly idempotent under seq dedup.

Comment thread server/ws-handler-v2.ts
// returns a stale value. This is safe — event delivery uses the
// transport (refreshed), and ownership checks on send/interrupt
// handle the rekey atomically.
const found = ctx.sessionRegistry.findBySessionId(entry.sessionId);

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 regressions: Reconnect now reattaches detached sessions (refreshing transport) but no longer rekeys the clientId. The inline comment (lines 259-265) documents this is safe because 'event delivery uses the transport (refreshed)'. However, between reattach and the first send, getOwnerConnection() returns the stale connectionId. If another connection attempts a send/interrupt for the same session during this window, the ownership check sees the stale owner as 'gone' (not in connRegistry) and performs a takeover — which is correct behavior but may trigger spurious takeover logs. Not a bug, but worth being aware of in production log analysis.

const postConnectionId = this._connectionId;
// Don't mark connected until POST succeeds — prevents send() from
// bypassing the pending queue and arriving before cursor setup.
this.doPost('reconnect', { type: 'reconnect', sessions }).then(

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 style: The reconnect POST .then(successCb, failureCb) form is harder to read than async/await with try/catch, especially since the success handler is 5 lines and failure handler is 4 lines. However, since the welcome event handler is synchronous and the POST is intentionally fire-and-forget, the .then() form avoids making the handler async. This is acceptable — just noting the trade-off.

expect(store.getSession('chat-2')!.symposiumConfig).toBeNull();
});

it('events table supports seat_id in payload', () => {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 missing_tests: The test 'events table supports seat_id in payload' only verifies that seatId round-trips through the JSON payload column. It doesn't test that the session associated with 'symp-4' was auto-created (upsertSession wasn't called). This works because append() auto-creates session rows, but makes the test implicitly dependent on that behavior. Consider calling store.upsertSession({ sessionId: 'symp-4', sessionType: 'symposium' }) first for clarity. [fixable]

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