From caf8fd95cf709f44f8b658b074b367af8e243321 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sat, 23 May 2026 08:48:59 +0200 Subject: [PATCH] make the transcript session-switch test deterministic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "session switch: newer file triggers replay" test relied on wall-clock mtimes to decide which .jsonl was newest. Under timing pressure the two files' mtimes could tie, so the reader never switched and the test timed out — it failed ~60% of full-suite runs (measured), the source of the intermittent red I'd been waving off as "a flake". Backdate session A to a fixed past time so the newer file is unambiguously newer; the switch is now guaranteed regardless of load. Co-Authored-By: Claude Opus 4.7 (1M context) --- .pql/changelog/ticket_history/2026-05.sql | 1 + .pql/changelog/tickets/2026-05.sql | 14 ++++++++++++++ test/builtin/claude/transcript_reader_test.dart | 16 ++++++++-------- 3 files changed, 23 insertions(+), 8 deletions(-) diff --git a/.pql/changelog/ticket_history/2026-05.sql b/.pql/changelog/ticket_history/2026-05.sql index 721c7e22..4659cf23 100644 --- a/.pql/changelog/ticket_history/2026-05.sql +++ b/.pql/changelog/ticket_history/2026-05.sql @@ -2192,3 +2192,4 @@ Open question to validate live: whether `claude --session-id ` re Acceptance: a secondary tab shows its OWN fresh conversation, independent of the primary; the primary keeps its conversation; both update live. Regression of T-137; relates to D-41. ', NULL, '2026-05-23 06:19:51', '2026-05-23 06:19:51', '2026-05-23 06:19:51', NULL, '9e46b8eec7cc5e42132249a81d4925d1', 1) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-146', 'status', 'backlog', 'in_progress', NULL, '2026-05-23 06:19:51', '2026-05-23 06:19:51', '2026-05-23 06:19:51', NULL, 'dd4ec6cfbbc3118cd4cf02604b91108b', 1) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-146', 'status', 'in_progress', 'done', NULL, '2026-05-23 06:30:47', '2026-05-23 06:30:47', '2026-05-23 06:30:47', NULL, '93f9c6814b9e82e5b60ac0e57fff5afe', 1) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-05.sql b/.pql/changelog/tickets/2026-05.sql index 21177fa5..d6488617 100644 --- a/.pql/changelog/tickets/2026-05.sql +++ b/.pql/changelog/tickets/2026-05.sql @@ -2536,3 +2536,17 @@ Open question to validate live: whether `claude --session-id ` re Acceptance: a secondary tab shows its OWN fresh conversation, independent of the primary; the primary keeps its conversation; both update live. Regression of T-137; relates to D-41. ', 'in_progress', 'high', NULL, NULL, NULL, '2026-05-23 06:19:51', '2026-05-23 06:19:51', NULL, 'c47ada73a71c18d1998cc6fdc36d1b22', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-146', 'bug', 'T-132', 'Secondary Claude panes render the primary''s transcript', 'Regression from T-137: every Claude pane renders the newest .jsonl in the workspace''s munged dir, so concurrent sessions in one workspace (primary + secondaries) all converge on the same transcript — a secondary tab shows the primary''s conversation (and the primary follows whichever session is newest). The pre-T-137 terminal pane rendered each tab''s own PTY, so tabs were independent. + +Fix: bind each pane to a specific Claude session id. +- claude CLI supports `--session-id ` (verified via --help). Spawn each pane''s claude with its own UUID; a transcript is named .jsonl, so the pane''s transcript is /.jsonl. +- Reader targets that exact file via TranscriptReader''s `file:` param (added in T-139). Guard _tail against the file not existing yet (claude writes it shortly after spawn). +- Each pane publishes/subscribes on a per-session MessageBus channel (conversation/) so primary and secondary controllers don''t cross-talk. +- UUID per pane, seeded by the tmux session name (the identity we already have): primary = deterministic from its stable session name (clide-claude-) so it RESUMES across restarts (D-41 continuity); secondary = random per spawn so each is a clean/fresh session (a deterministic secondary id would make starting a clean session impossible). + +Migration caveat: an existing tmux session created by the OLD code (no --session-id, claude picked its own id) won''t match the new deterministic id — it must be killed (claude.kill-all-sessions) once so the next spawn binds the controlled id. + +Open question to validate live: whether `claude --session-id ` resumes vs. errors when re-spawned after the tmux session was killed (display works either way since the file is .jsonl, but worth confirming claude starts). + +Acceptance: a secondary tab shows its OWN fresh conversation, independent of the primary; the primary keeps its conversation; both update live. Regression of T-137; relates to D-41. +', 'done', 'high', NULL, NULL, NULL, '2026-05-23 06:19:51', '2026-05-23 06:30:47', NULL, '2e1843c9b540a3d7ced03052d9efcbc4', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); diff --git a/test/builtin/claude/transcript_reader_test.dart b/test/builtin/claude/transcript_reader_test.dart index 4bb49599..85c123d0 100644 --- a/test/builtin/claude/transcript_reader_test.dart +++ b/test/builtin/claude/transcript_reader_test.dart @@ -620,8 +620,12 @@ void main() { await projectDir.create(recursive: true); final sessionFile = File('${projectDir.path}/session-abc.jsonl'); - // Write session A. + // Write session A and backdate it to a fixed past time. This makes + // "newest by mtime" deterministic: relying on wall-clock mtimes lets + // the two files tie under timing pressure, so the reader never + // switches and the test flakes. A fixed old mtime removes the race. writeLines(sessionFile, [userText('u1', 'old session')]); + await sessionFile.setLastModified(DateTime.utc(2020)); final reader = TranscriptReader( workspace, @@ -633,15 +637,11 @@ void main() { await pumpUntil(() => collected.whereType().any((m) => m.text == 'old session')); - // Create a newer session file (ensure mtime difference with touch-like approach). + // A newer session file — its mtime (now, then pinned to 2021) is + // unambiguously after session A's 2020, so the switch is guaranteed. final newerFile = File('${projectDir.path}/session-xyz.jsonl'); writeLines(newerFile, [userText('u2', 'new session')]); - // Force a newer mtime (sleep is disallowed in long loops but a short - // fixed delay within a test is acceptable as a one-shot wait). - await Future.delayed(const Duration(milliseconds: 100)); - // Re-touch the newer file to guarantee mtime is after the old one. - final now = DateTime.now(); - await newerFile.setLastModified(now); + await newerFile.setLastModified(DateTime.utc(2021)); await pumpUntil(() => collected.whereType().any((m) => m.text == 'new session'));