fix(session-core): stream id collisions, wire-status narrowing, and two vanished conventions - #886
Open
Harry19081 wants to merge 4 commits into
Open
fix(session-core): stream id collisions, wire-status narrowing, and two vanished conventions#886Harry19081 wants to merge 4 commits into
Harry19081 wants to merge 4 commits into
Conversation
`createStreamMessageId` and `createStreamThinkingId` used `Date.now()` as their only nonce, so two placeholders minted for one session inside the same millisecond were byte-identical. The id keys an `eventStoreProxy` upsert, and upsert merges on id — so they were not two events: the second stream's text overwrote the first stream's row and two logically distinct streams collapsed into one transcript entry. Both call sites mint a placeholder only when the lane id is null, which is exactly the state `reset()` leaves behind, so a fast turn boundary is the trigger. Confirmed by driving the real handler through delta/reset/delta with the clock pinned: pre-fix it reproduces the same signature the intermittent `drops the live stream state` test emits. Fixed with a module-level monotonic counter, zero-padded, appended after the timestamp. A counter rather than a uuid because `compareChatEvents` (snapshotMaterialization.orderMembership.ts) and the raw-transcript dialog both break `createdAt` ties with `id.localeCompare` — same-millisecond placeholders share a `createdAt`, so the id is the chat ordering key. A uuid would randomise that order; a padded counter keeps lexicographic order equal to creation order. Zero-padding matters: unpadded, `-10` sorts before `-2`. The counter never resets, so uniqueness no longer depends on the wall clock advancing — a stalled clock or a backwards NTP step still yields distinct ids. This mirrors what Rust already does for its authoritative ids in agent-core's streaming module. The `stream-msg-ts-` / `stream-think-ts-` prefixes are load-bearing across the FFI boundary — session-persistence matches on them to keep live-only rows out of the DB — and the id must stay colon-free or `parseActivityId` would reinterpret it. Both invariants are now pinned by assertions.
Same defect as the one fixed on develop in sessionSyncReconcile: a raw wire string force-cast into `Session.status`, which drives sidebar grouping, Kanban lanes and every terminal-status predicate. Each site now narrows with `toCliSessionStatus` and maps with `toSessionListStatus`. The four differed in how exposed they were, and the fix reflects that: sessionSyncStateHelpers.ts:181 — completely ungated. `runStatus` is typed `string` and the only early return above fires on a different condition, so `installing` and any unknown value landed verbatim. useNativeSessionStatusMonitor.ts:133 — the widest door. Straight off the `session-status-changed` Tauri payload, written unconditionally for every session, foreground or background. sessionSyncStateHelpers.ts:269 and cliTurnLifecycleCoordinator.ts:134 — runtime-safe today, but only because a `Set` membership guard upstream happens to contain nothing outside the union. The cast was unproven, not wrong; the guard was load-bearing without saying so. cliTurnLifecycleCoordinator also had a second, upstream cast that was the real laundering point: `event.status as CliSessionStatus` on the RPC payload at line 80. Narrowing there means the terminal guard, the turn-lifecycle writes and the row write all see a validated value. Behaviour is unchanged — an unknown status narrows to `idle`, which is neither running nor terminal, so it is ignored exactly as before, now with a warning. `installing` maps to `running` at all four sites, consistent with every consumer of the field: RUNNING_SESSION_STATUSES and IN_PROGRESS_STATUSES both group it that way. Sites 1 and 4 can actually receive it; the other two are terminal-only, and tests pin that it never reaches the row there. On the two guarded sites, reverting the cast alone does not fail — the guard still blocks it. Rather than claim a bite that could not be shown, those tests assert union membership and go red when the guard is widened while a cast is present, which is how the defect would actually reappear.
The 53-directory placement cleanup landed with the convention written up in CONTRIBUTING.md and nothing enforcing it. Two directories re-mixed within a day, both from a commit that merged after the cleanup: UnifiedModelPalette and SearchContent/components. Documentation alone did not hold for 24 hours. Adds `scripts/quality/check-test-placement.mjs`, wired to `pnpm run check:test-placement` and a CI step alongside lint. It groups tests by the directory that owns them — a `__tests__/x.test.ts` counts against its parent — and fails when a directory holds both styles, naming the offending directory and exactly which files to move. On a tie it names the colocated file as the one to move, not the `__tests__/` one: a tie means the newcomer should join the incumbent, and both regressions here were exactly that shape. Both stragglers are moved into their directory's existing `__tests__/`, with their relative imports re-pointed. The check now reports consistent placement across 418 directories.
CLAUDE.md and AGENTS.md route all UI-component work to `frontend-ui-audit`, but no SKILL.md existed at either the user-global or the workspace path. This was not cosmetic: 55 reports under docs/frontend-ui-audit-* between 2026-07-12 and 2026-08-22 carry a "skill was unavailable, manual fallback" note. Every UI audit for six weeks was improvised. The root cause is in .gitignore, not in the file. `.orgii/skills/*` is ignored and each vendored skill is re-admitted by an explicit pair of negation rules. frontend-ui-audit was never added to that allowlist, so it was only ever a local file on one machine and disappeared with it, while its five siblings survived because they were allowlisted. This adds the missing pair, so the skill is now tracked by the same mechanism as the others rather than by a force-add that the next person would have to know about. The SKILL.md itself is reconstructed from the conventions the 45 report directories actually follow — the five dimensions, the verdict vocabulary (fix / keep with reason / abstract / watch), the mandatory Reason column, the report path, and the decision rules the reports encode: the D2 bridge-layer exemption, concentration mattering more than raw count, fixing design-system components first because the gap multiplies across consumers, the 3+ threshold for abstract, and Sweep Discipline counting a multi-file fix as one candidate. It says at the top that it is a reconstruction and invites correction, since it was rebuilt from output rather than restored from an original. The routing-doc updates that point at it landed in the previous commit.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Four follow-ups from the test-suite audit in #879, each traced to a root cause rather than patched where it surfaced.
Session.status, same defect as the one fixed in test: repair dead test wiring, normalize placement, cover high-risk modules #879frontend-ui-auditskill restored — and the.gitignorerule that made it disappear, fixedProblem
1. Stream ids collided.
createStreamMessageId/createStreamThinkingIdusedDate.now()as their only nonce. The id keys aneventStoreProxyupsert, and upsert merges on id — so two placeholders minted for one session in the same millisecond were not two events. The second stream's text overwrote the first stream's row, collapsing two logically distinct streams into one transcript entry. Both call sites mint a placeholder only when the lane id is null, which is exactly whatreset()leaves behind, so a fast turn boundary is the trigger.2. Four unvalidated wire statuses. #879 fixed one instance in
sessionSyncReconcile; four remained.Session.statusdrives sidebar grouping, Kanban lanes and every terminal-status predicate, and a value outside the union reached it.3.
frontend-ui-audithad no SKILL.md at either the user-global or workspace path, while CLAUDE.md and AGENTS.md route all UI-component work to it. Not cosmetic: 55 reports underdocs/frontend-ui-audit-*between 2026-07-12 and 2026-08-22 carry a "skill was unavailable, manual fallback" note. Every UI audit for six weeks was improvised.4. The placement convention regressed in under a day. #879 normalized 53 mixed directories to 0 and wrote the rule into
CONTRIBUTING.md. Two directories re-mixed almost immediately, both from a commit that merged after the cleanup.Solution
Stream ids — a counter, deliberately not a UUID
A module-level monotonic counter, zero-padded, appended after the timestamp.
compareChatEvents(snapshotMaterialization.orderMembership.ts:84) and the raw-transcript dialog (transcript.ts:37) both breakcreatedAtties withid.localeCompare. Same-millisecond placeholders share acreatedAt, so the id is the chat ordering key. A UUID would randomize transcript order; a padded counter keeps lexicographic order equal to creation order. Zero-padding matters — unpadded,-10sorts before-2.The counter never resets, so uniqueness no longer depends on the wall clock advancing: a stalled clock or a backwards NTP step still yields distinct ids. This mirrors what Rust already does for its authoritative ids in agent-core's streaming module, whose own comment describes the same upsert-overwrite hazard.
Two constraints found and now pinned by assertions: the
stream-msg-ts-/stream-think-ts-prefixes are load-bearing across the FFI boundary (session-persistence/src/crud.rs:26matches on them to keep live-only rows out of the DB), and the id must stay colon-free orparseActivityIdreinterprets it.Confirmed by driving the real handler through
delta → reset() → deltawith the clock pinned: pre-fix it reproduces the exact signature the intermittentdrops the live stream statetest emits.The four casts — graded by exposure
sessionSyncStateHelpers.ts:181runStatusis typedstring; the only early return above fires on a different condition.useNativeSessionStatusMonitor.ts:133session-status-changedTauri payload, written unconditionally for every session.sessionSyncStateHelpers.ts:269Setguard upstream happens to hold nothing outside the union.cliTurnLifecycleCoordinator.ts:134cliTurnLifecycleCoordinatoris narrowed at its entry point so the terminal guard, the turn-lifecycle writes and the row write all see a validated value. Behavior is unchanged: an unknown status narrows toidle, neither running nor terminal, so it is ignored exactly as before — now with a warning.installingmaps torunningat all four sites, consistent withRUNNING_SESSION_STATUSESandIN_PROGRESS_STATUSES.On evidence quality: reverting the cast alone on the two guarded sites does not fail — the guard still blocks it. Rather than claim a bite that could not be shown, those tests assert union membership and go red when the guard is widened while a cast is present, which is how the defect would actually reappear. The three-way matrix (cast+widened guard = red, fix+widened guard = green) is in the commit body.
The skill — fixing the cause, not the symptom
The root cause is in
.gitignore, not the file..orgii/skills/*is ignored, and each vendored skill is re-admitted by an explicit pair of negation rules.frontend-ui-auditwas never added to that allowlist, so it only ever existed as a local file on one machine and vanished with it, while its five siblings survived. This adds the missing pair, so it is tracked by the same mechanism as the others rather than a force-add the next person would have to know about.The SKILL.md is reconstructed from the conventions the 45 report directories actually follow: the five dimensions, the verdict vocabulary, the mandatory Reason column, and the decision rules the reports encode — the D2 bridge-layer exemption, concentration over raw count, fixing design-system components first because the gap multiplies, the 3+ threshold for
abstract, and Sweep Discipline counting a multi-file fix as one candidate. It says so at the top and invites correction.Placement — enforced
scripts/quality/check-test-placement.mjs, wired topnpm run check:test-placementand a CI step beside lint. It groups tests by the directory that owns them and names both the offending directory and which files to move. On a tie it names the colocated file — a tie means the newcomer should join the incumbent, and both regressions were exactly that shape.Potential risks
fix(session-core)commits change runtime behavior. The status narrowing is behavior-preserving by inspection of every consumer. The id format change is additive — no consumer parses the suffix — but it does alter tie-break ordering for same-millisecond events, which is the point.as SessionStatuscasts are deliberately untouched —agentLiveStatusAtom.ts:40(already guarded by aSetmembership test),SessionService.ts:208andlaunchPayload.ts:324(different shapes, different provenance).Validation
npx vitest runnpx tsc --noEmitpnpm run check:test-placementBaseline before this branch: 1166 files / 9560 tests.
Rust is untouched, so
cargo test --workspacewas not re-run; #879's gate covers it on this branch's base.