6 Commits
Author SHA1 Message Date
jpmschweitzerandClaude Opus 4.8 4fa4ce1bac release v2.3.2
Patch release: the real fix for the ticket/decision sidebars failing on
first load — IPC-server swaps are now serialized so the repo workspace
bind always wins over the boot launch-CWD bind (T-352). Supersedes the
partial 2.3.1 re-fetch-on-open, which is kept for mid-session switches.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 08:45:17 +02:00
jpmschweitzerandClaude Opus 4.8 7d951a247f main: serialize IPC-server swaps so the repo bind wins (T-352)
The 2.3.1 fix (re-fetch the pql sidebars on ProjectOpened) only helped
the picker-first path, where the project opens after the window is up.
With sticky-startup the project opens during boot, before the panes
mount and subscribe, so they never received the event — the sidebars
stayed broken.

Root cause is a race in the IPC-server lifecycle. The boot factory fires
swapIpcServer(launchCwd) with unawaited(); the project-open flow then
fires swapIpcServer(repo). Each swap stops the live server, binds a new
one, and reconnects the daemon client. Unserialized, the two interleave
and the late-finishing boot swap can clobber the repo bind, reconnecting
the client to the launch-CWD (HOME) socket. The daemon's PqlClient (and
git/files) then run against the wrong workspace, so the first
pql.tickets.list hits a stale/global pql.db and errors
("ticket_deps.blocker_record_id missing — pql.db is from an earlier
schema"). A manual refresh worked because by then things had settled.

Chain every swap on a serialization Future so they apply in call order;
the repo swap is issued last and therefore wins. Kept the pane re-fetch
from 2.3.1 — it still covers genuine mid-session project switches.

Verified app boot is unaffected (test/app_test.dart green).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 08:44:17 +02:00
jpmschweitzerandClaude Opus 4.8 138872e990 release v2.3.1
Patch release: ticket/decision sidebars load on first open (T-352), plus
the KWin frameless-chrome map fix (T-351) and the transient pql-failure
retry (T-350) that landed since 2.3.0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 20:40:09 +02:00
jpmschweitzerandClaude Opus 4.8 a0501b4a0a pql: refetch ticket + decision sidebars when the workspace opens (T-352)
On a desktop launch the daemon's PqlClient boots with workDir set to the
launch CWD (e.g. HOME), not the repo — swapIpcServer only rewires it once
the project opens. The tickets and decisions panes fire their first pql
fetch before that swap, so pql runs in the wrong directory against a
stale/global pql.db and the pane errors (observed:
"ticket_deps.blocker_record_id missing — pql.db is from an earlier
schema"). A manual refresh worked because by then the workspace was open.

This is a wrong-workDir timing issue, not db-busy, so the T-350 retry
doesn't catch it. Both panes now re-fetch on ProjectOpened, which fires
after the IPC server swaps to the project workRoot.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 20:38:44 +02:00
jpmschweitzerandClaude Opus 4.8 7cea13c5c0 linux: request no-decorations on map, not just realize (T-351)
On KDE Plasma 6 / KWin 6 the frameless chrome still showed the native
title bar even with the decoration code compiled in. The KDE
server-decoration request ran on the GtkWidget "realize" signal, but
GTK's Wayland backend only creates the wl_surface on map — so at realize
gdk_wayland_window_get_wl_surface() was null and the request bailed,
leaving KWin (which defaults to server-side decorations on Wayland) to
draw its title bar.

Also connect the handler to "map", where the surface is live. The realize
pass still does the X11 gdk_window_set_decorations hint and bails harmlessly
on the Wayland part, so no duplicate decoration object is created.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 20:24:50 +02:00
jpmschweitzerandClaude Opus 4.8 9a8175903b pql: retry transient db-busy so sidebar panes don't stick (T-350)
The pql-backed sidebar panes fetch once on first build. If that fetch
fired too early — the planning DB still settling at startup, or a db-busy
SQLite lock under concurrent pql writes (pql exits 69) — the pane showed
"pql … failed" and stayed there until a manual refresh re-fired it.

Retry transient failures at the single chokepoint, PqlClient._run: on a
busy/locked signal (exit 69, or stderr mentioning database is locked /
busy) retry a few times with short backoff before throwing. Genuine
errors aren't busy, so they still surface immediately.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 20:20:36 +02:00
14 changed files with 241 additions and 29 deletions
@@ -3510,3 +3510,7 @@ INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, chang
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB4XCM5KBXDDSCWJ37GPYG3R', 'status', 'backlog', 'done', NULL, '2026-06-10 16:57:07', '2026-06-10 16:57:07', '2026-06-10 16:57:07', NULL, '1ce291d54831d3ede687384037c379ff', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB50YE6S6YWNP2ZSFWES9B2W', 'status', 'backlog', 'done', NULL, '2026-06-10 17:13:53', '2026-06-10 17:13:53', '2026-06-10 17:13:53', NULL, '87b069d3f25c91622c9720147d359360', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB58X0TFJ02YTMVPD0D9Q838', 'status', 'backlog', 'done', NULL, '2026-06-10 17:47:01', '2026-06-10 17:47:01', '2026-06-10 17:47:01', NULL, 'ba3d17ec4e668ade82b07d2bb848ab91', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB5CW7JPT6BR2RWMNYVCXJ50', 'status', 'backlog', 'done', NULL, '2026-06-10 18:20:28', '2026-06-10 18:20:28', '2026-06-10 18:20:28', NULL, '213109fcd67b4375ebbd3b59c4a1ed04', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB5HMYDXP62RKH3HP55T6AYG', 'status', 'backlog', 'review', NULL, '2026-06-10 18:24:41', '2026-06-10 18:24:41', '2026-06-10 18:24:41', NULL, 'c5a88f9594c44896a6a3d1a4b2418ed2', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB5HMYDXP62RKH3HP55T6AYG', 'status', 'review', 'done', NULL, '2026-06-10 18:27:58', '2026-06-10 18:27:58', '2026-06-10 18:27:58', NULL, 'a9c72ab53b69f5ca6bf1fa4dd0ddfa05', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB5M14B76B31654D959XM5AC', 'status', 'backlog', 'done', NULL, '2026-06-10 18:38:53', '2026-06-10 18:38:53', '2026-06-10 18:38:53', NULL, 'b94cfe8ba315b3be6775474c681b4e80', 2) ON CONFLICT(hash) DO NOTHING;
+3
View File
@@ -174,3 +174,6 @@ INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB4XCM5KBXDDSCWJ37GPYG3R', 'T-347', '2026-06-10 16:55:10', '2026-06-10 16:55:10', NULL, '9d40226dbc6136072d2d5d0eda71f141', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB50YE6S6YWNP2ZSFWES9B2W', 'T-348', '2026-06-10 17:10:42', '2026-06-10 17:10:42', NULL, '8bb5ad92551f272417840869a0774668', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB58X0TFJ02YTMVPD0D9Q838', 'T-349', '2026-06-10 17:45:28', '2026-06-10 17:45:28', NULL, '0b12f14fa83254ab113c870531628359', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB5CW7JPT6BR2RWMNYVCXJ50', 'T-350', '2026-06-10 18:02:50', '2026-06-10 18:02:50', NULL, 'a5c0c22d84621b14a5208317414d6026', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB5HMYDXP62RKH3HP55T6AYG', 'T-351', '2026-06-10 18:23:41', '2026-06-10 18:23:41', NULL, '9d2da44c16c5aa38c0a36e4b00ef5f15', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB5M14B76B31654D959XM5AC', 'T-352', '2026-06-10 18:34:05', '2026-06-10 18:34:05', NULL, '3fe3e1d5fb7c0fbd084b45116575ad98', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
+27
View File
@@ -3213,3 +3213,30 @@ INSERT INTO tickets (record_id, type, parent_record_id, title, description, stat
So a build host/container missing the Wayland client dev headers (very plausible in a Bazzite/immutable distrobox or toolbox) silently compiles out the decoration suppression — the rebuilt app ships with the compositor''s native title bar (double title bar on KDE Plasma Wayland). Reported live on Bazzite KDE.
Fix: make wayland-client a hard build requirement — fail the CMake configure with a clear, actionable message (name the package: Fedora wayland-devel, Debian/Ubuntu libwayland-dev) instead of dropping the feature. Frameless chrome is a core guardrail; never ship without it. Also fix the stale ''xdg-decoration'' comment (the code uses the KDE server-decoration protocol, not xdg-decoration).', 'done', 'high', NULL, NULL, NULL, '2026-06-10 17:45:28', '2026-06-10 17:47:01', NULL, '6eb6120c0ef64a7f5ce78073b398143c', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_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 (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB5CW7JPT6BR2RWMNYVCXJ50', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'pql sidebar panes stick on error when the first fetch fires too early', 'The pql-backed sidebar panes (tickets, decisions, pql, search) fetch once on first build via pql.* IPC. If that first fetch hits a TRANSIENT pql failure — the planning DB still settling at startup, or a db-busy SQLite lock under concurrent pql writes (pql 1.10 exits 69 ''db busy'', as seen in the serial test suite) — the pane shows ''pql ticket failed'' and stays there until a manual refresh / tab-switch re-fires the fetch. Reproduced live on 2.3.0: left pane ''pql ticket failed''; works on manual refresh. pql works fine in isolation, so it''s purely a too-early / transient timing issue with no retry.
Fix: make pql invocations resilient to transient failures at the single chokepoint, PqlClient._run (lib/src/pql/client.dart) — on a busy/locked signal (exit 69, or stderr mentioning database is locked / busy), retry a small bounded number of times with short backoff before throwing PqlException. Keep genuine errors fast (don''t blanket-retry every non-zero). Fixes all pql panes at once.', 'backlog', 'high', NULL, NULL, NULL, '2026-06-10 18:02:50', '2026-06-10 18:02:50', NULL, 'd03c864f5e64284f3582e59556a4ab4f', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_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 (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB5CW7JPT6BR2RWMNYVCXJ50', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'pql sidebar panes stick on error when the first fetch fires too early', 'The pql-backed sidebar panes (tickets, decisions, pql, search) fetch once on first build via pql.* IPC. If that first fetch hits a TRANSIENT pql failure — the planning DB still settling at startup, or a db-busy SQLite lock under concurrent pql writes (pql 1.10 exits 69 ''db busy'', as seen in the serial test suite) — the pane shows ''pql ticket failed'' and stays there until a manual refresh / tab-switch re-fires the fetch. Reproduced live on 2.3.0: left pane ''pql ticket failed''; works on manual refresh. pql works fine in isolation, so it''s purely a too-early / transient timing issue with no retry.
Fix: make pql invocations resilient to transient failures at the single chokepoint, PqlClient._run (lib/src/pql/client.dart) — on a busy/locked signal (exit 69, or stderr mentioning database is locked / busy), retry a small bounded number of times with short backoff before throwing PqlException. Keep genuine errors fast (don''t blanket-retry every non-zero). Fixes all pql panes at once.', 'done', 'high', NULL, NULL, NULL, '2026-06-10 18:02:50', '2026-06-10 18:20:28', NULL, 'd48c86ad83f3da7cd4f8c9308c852448', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_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 (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB5HMYDXP62RKH3HP55T6AYG', 'bug', NULL, 'Frameless chrome: KWin keeps the title bar — decoration request fires before the wl_surface exists', 'On KDE Plasma 6 / KWin 6 (Wayland), clide still shows the compositor''s native title bar even though the frameless code (D-057, KDE server-decoration protocol) is compiled in (confirmed: with T-349 making wayland-client REQUIRED, a 2.3.0 build that runs at all has it).
Root cause is timing, not the protocol. linux/runner/clide_app.cc connects request_no_server_decorations() to the GtkWidget ''realize'' signal, but GTK''s Wayland backend only creates the wl_surface on MAP, not realize. So gdk_wayland_window_get_wl_surface() returns null and the function bails at its own ''if (surface == nullptr) return'' before creating the org_kde_kwin_server_decoration / requesting mode NONE. KWin 6 defaults to server-side decorations on Wayland unless that request lands → native title bar shows (double title bar with clide''s own chrome).
Fix: also fire the request on the ''map'' signal (wl_surface is live by then); the realize handler still does the X11 gdk_window_set_decorations hint and harmlessly bails on the Wayland part (surface null) so no duplicate decoration object is created. If KWin 6 turns out not to honor the legacy KDE protocol, fall back to the standard xdg-decoration protocol (zxdg_decoration_manager_v1, set_mode CLIENT_SIDE).', 'backlog', 'high', NULL, NULL, NULL, '2026-06-10 18:23:41', '2026-06-10 18:23:41', NULL, 'ec216013c0bb79f1f11a87979c9fb886', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_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 (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB5HMYDXP62RKH3HP55T6AYG', 'bug', NULL, 'Frameless chrome: KWin keeps the title bar — decoration request fires before the wl_surface exists', 'On KDE Plasma 6 / KWin 6 (Wayland), clide still shows the compositor''s native title bar even though the frameless code (D-057, KDE server-decoration protocol) is compiled in (confirmed: with T-349 making wayland-client REQUIRED, a 2.3.0 build that runs at all has it).
Root cause is timing, not the protocol. linux/runner/clide_app.cc connects request_no_server_decorations() to the GtkWidget ''realize'' signal, but GTK''s Wayland backend only creates the wl_surface on MAP, not realize. So gdk_wayland_window_get_wl_surface() returns null and the function bails at its own ''if (surface == nullptr) return'' before creating the org_kde_kwin_server_decoration / requesting mode NONE. KWin 6 defaults to server-side decorations on Wayland unless that request lands → native title bar shows (double title bar with clide''s own chrome).
Fix: also fire the request on the ''map'' signal (wl_surface is live by then); the realize handler still does the X11 gdk_window_set_decorations hint and harmlessly bails on the Wayland part (surface null) so no duplicate decoration object is created. If KWin 6 turns out not to honor the legacy KDE protocol, fall back to the standard xdg-decoration protocol (zxdg_decoration_manager_v1, set_mode CLIENT_SIDE).', 'review', 'high', NULL, NULL, NULL, '2026-06-10 18:23:41', '2026-06-10 18:24:41', NULL, '0d06f87ff4d71e2ee4e4821968b5e3df', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_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 (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB5HMYDXP62RKH3HP55T6AYG', 'bug', NULL, 'Frameless chrome: KWin keeps the title bar — decoration request fires before the wl_surface exists', 'On KDE Plasma 6 / KWin 6 (Wayland), clide still shows the compositor''s native title bar even though the frameless code (D-057, KDE server-decoration protocol) is compiled in (confirmed: with T-349 making wayland-client REQUIRED, a 2.3.0 build that runs at all has it).
Root cause is timing, not the protocol. linux/runner/clide_app.cc connects request_no_server_decorations() to the GtkWidget ''realize'' signal, but GTK''s Wayland backend only creates the wl_surface on MAP, not realize. So gdk_wayland_window_get_wl_surface() returns null and the function bails at its own ''if (surface == nullptr) return'' before creating the org_kde_kwin_server_decoration / requesting mode NONE. KWin 6 defaults to server-side decorations on Wayland unless that request lands → native title bar shows (double title bar with clide''s own chrome).
Fix: also fire the request on the ''map'' signal (wl_surface is live by then); the realize handler still does the X11 gdk_window_set_decorations hint and harmlessly bails on the Wayland part (surface null) so no duplicate decoration object is created. If KWin 6 turns out not to honor the legacy KDE protocol, fall back to the standard xdg-decoration protocol (zxdg_decoration_manager_v1, set_mode CLIENT_SIDE).', 'done', 'high', NULL, NULL, NULL, '2026-06-10 18:23:41', '2026-06-10 18:27:58', NULL, 'e61eeca3f6ccf42a334867c757fda759', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_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 (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB5M14B76B31654D959XM5AC', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Ticket sidebar errors on first load: pql runs before the workspace workDir is set', 'The tickets sidebar (and other pql panes) fail on first load when clide is desktop-launched: the daemon''s PqlClient is constructed with workDir = the boot CWD (the launch dir, e.g. ~), not the repo. The pane''s first pql.tickets.list fires before swapIpcServer reconfigures the dispatcher with the project''s workRoot, so pql runs in the wrong dir — against a stale/global pql.db — and errors (observed: ''ticket_deps.blocker_record_id missing — pql.db is from an earlier schema''). A manual refresh works because by then the workspace is open and the workDir is correct.
This is a wrong-workDir timing issue, not db-busy (so the T-350 retry doesn''t catch it). Fix: the pql-backed panes refetch on ProjectOpened (which fires after the IPC server swaps to the project workRoot). Implemented for the tickets pane; the decisions/pql/search panes share the latent bug and should get the same refetch.', 'backlog', 'high', NULL, NULL, NULL, '2026-06-10 18:34:05', '2026-06-10 18:34:05', NULL, 'a6d1821c06dbccb30c9f7e777aef7ef5', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_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 (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB5M14B76B31654D959XM5AC', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Ticket sidebar errors on first load: pql runs before the workspace workDir is set', 'The tickets sidebar (and other pql panes) fail on first load when clide is desktop-launched: the daemon''s PqlClient is constructed with workDir = the boot CWD (the launch dir, e.g. ~), not the repo. The pane''s first pql.tickets.list fires before swapIpcServer reconfigures the dispatcher with the project''s workRoot, so pql runs in the wrong dir — against a stale/global pql.db — and errors (observed: ''ticket_deps.blocker_record_id missing — pql.db is from an earlier schema''). A manual refresh works because by then the workspace is open and the workDir is correct.
This is a wrong-workDir timing issue, not db-busy (so the T-350 retry doesn''t catch it). Fix: the pql-backed panes refetch on ProjectOpened (which fires after the IPC server swaps to the project workRoot). Implemented for the tickets pane; the decisions/pql/search panes share the latent bug and should get the same refetch.', 'done', 'high', NULL, NULL, NULL, '2026-06-10 18:34:05', '2026-06-10 18:38:53', NULL, '3891f832c1d930d8fefca2fae7ffea86', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_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);
+33
View File
@@ -16,6 +16,39 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
## [Unreleased]
## [2.3.2] — 2026-06-11
### Fixed
- **Ticket and decision sidebars reliably load on first open (real fix).** The
2.3.1 re-fetch-on-open helped only when a project is picked *after* the window
is up; with sticky-startup the project opens during boot, before the panes
mount, so they never saw the event. The underlying cause was a race: the boot
IPC-server swap (to the launch CWD) and the project-open swap (to the repo)
ran concurrently, and the late-finishing boot swap could clobber the repo
bind — leaving the daemon's pql/git/files pointed at the launch directory
(HOME) and the sidebars erroring on a stale/global pql.db. Swaps are now
serialized so the repo bind always wins. (T-352)
## [2.3.1] — 2026-06-10
### Fixed
- **Frameless window chrome works on KDE Plasma 6 / KWin 6.** The Wayland
server-decoration request fired on `realize`, before GTK created the
surface, so it bailed and KWin (which defaults to server-side decorations)
kept drawing its own title bar. It now also fires on `map`. (T-351)
- **pql sidebar panes no longer stick on a transient startup error.** A
too-early or db-busy pql failure (the planning DB still settling, or a
SQLite lock under concurrent writes) is now retried a few times before
surfacing, instead of leaving the pane on "pql … failed" until a manual
refresh. (T-350)
- **Ticket and decision sidebars load on first open, not just after a manual
refresh.** On a desktop launch the daemon's pql workspace starts as the
launch directory, not the repo, so the panes' first fetch ran against the
wrong (or a stale-schema) DB and errored. They now re-fetch when the
workspace actually opens, by which point the pql workspace is the repo. (T-352)
## [2.3.0] — 2026-06-10
### Fixed
+1 -1
View File
@@ -39,7 +39,7 @@ self:
# Auto-synced from pubspec.yaml `version:` by `make gen-build-info`
# (runs implicitly on every build/run/test). Don't hand-edit; bump
# pubspec instead.
version: "2.3.0"
version: "2.3.2"
homepage: https://github.com/postmeridiem/clide
license: MIT
license_file: assets/LICENSE
@@ -23,6 +23,7 @@ class _DecisionsViewState extends State<DecisionsView> {
StreamSubscription<Message>? _focusSub;
StreamSubscription<DaemonEvent>? _fileSub;
StreamSubscription<SchedulerTick>? _schedulerSub;
StreamSubscription<ProjectOpened>? _projectSub;
bool _refreshing = false;
bool _pendingRefresh = false;
@@ -37,6 +38,12 @@ class _DecisionsViewState extends State<DecisionsView> {
.where((e) => e.subsystem == 'files' && e.kind == 'files.changed' && _isDecisionPath(e.data['path'] as String? ?? ''))
.listen((_) => _refresh());
_schedulerSub = kernel.events.on<SchedulerTick>().where((e) => e.tier == SchedulerTier.oneMinute).listen((_) => _refresh());
// The first load can fire before the project's workspace is wired into
// the daemon (the boot workDir is the launch CWD, not the repo), so pql
// runs against the wrong/old DB and the list errors. Re-fetch once the
// workspace is actually open — ProjectOpened fires after the IPC server
// swaps to the project workRoot. (T-352)
_projectSub = kernel.events.on<ProjectOpened>().listen((_) => _refresh());
}
if (!_loading || _decisions.isNotEmpty) return;
unawaited(_load());
@@ -88,6 +95,7 @@ class _DecisionsViewState extends State<DecisionsView> {
_focusSub?.cancel();
_fileSub?.cancel();
_schedulerSub?.cancel();
_projectSub?.cancel();
super.dispose();
}
@@ -37,6 +37,7 @@ class _TicketsViewState extends State<TicketsView> {
StreamSubscription<Message>? _focusSub;
StreamSubscription<SchedulerTick>? _schedulerSub;
StreamSubscription<Message>? _changedSub;
StreamSubscription<ProjectOpened>? _projectSub;
bool _refreshing = false;
bool _pendingRefresh = false;
@@ -111,6 +112,12 @@ class _TicketsViewState extends State<TicketsView> {
}));
});
_schedulerSub = kernel.events.on<SchedulerTick>().where((e) => e.tier == SchedulerTier.oneMinute).listen((_) => _refresh());
// The first load can fire before the project's workspace is wired into
// the daemon (the boot workDir is the launch CWD, not the repo), so pql
// runs against the wrong/old DB and the list errors. Re-fetch once the
// workspace is actually open — the daemon's pql workDir is correct by
// then (ProjectOpened fires after the IPC server swaps). (T-352)
_projectSub = kernel.events.on<ProjectOpened>().listen((_) => _refresh());
}
if (!_loading || _tickets.isNotEmpty) return;
unawaited(_load());
@@ -121,6 +128,7 @@ class _TicketsViewState extends State<TicketsView> {
_focusSub?.cancel();
_changedSub?.cancel();
_schedulerSub?.cancel();
_projectSub?.cancel();
super.dispose();
}
+21 -1
View File
@@ -116,7 +116,11 @@ Future<void> main() async {
McpServer? mcpServer;
final ipcLog = Logger();
Future<void> swapIpcServer(DaemonDispatcher dispatcher, Directory workRoot) async {
// IPC-server swaps must run one-at-a-time — see the swapIpcServer wrapper
// below doSwapIpcServer for why. (T-352)
Future<void> swapChain = Future<void>.value();
Future<void> doSwapIpcServer(DaemonDispatcher dispatcher, Directory workRoot) async {
if (kIsWeb) return;
// Already serving this exact workspace? Reuse the live server.
// The startup factory binds the launch CWD, then the project-open
@@ -173,6 +177,22 @@ Future<void> main() async {
}
}
// Serialize IPC-server swaps. The boot factory fires a swap to the launch
// CWD with unawaited(); the project-open flow then fires another to the
// real repo. Unserialized, the two interleave and the late-finishing boot
// swap can clobber the repo bind — reconnecting the daemon client to the
// launch-CWD (HOME) socket, so pql/git/files run against the wrong
// workspace. That surfaced as the ticket/decision sidebars failing on first
// load (stale/global pql.db) yet working after a manual refresh. Chaining
// every swap makes them apply in call order; the repo swap is issued last
// and therefore wins. (T-352)
Future<void> swapIpcServer(DaemonDispatcher dispatcher, Directory workRoot) {
final next = swapChain.then((_) => doSwapIpcServer(dispatcher, workRoot));
// A failed swap must not break the chain for the next one.
swapChain = next.catchError((Object _) {});
return next;
}
DaemonDispatcher buildDispatcher(
DaemonBus events,
Toolchain tc,
+26
View File
@@ -162,7 +162,13 @@ class PqlClient {
return const {};
}
/// pql's exit code for a locked / unavailable planning DB (EX_UNAVAILABLE) —
/// a transient SQLite-busy condition under concurrent access (T-350).
static const int _kBusyExitCode = 69;
static const int _kMaxAttempts = 4;
Future<Object?> _run(List<String> args) async {
for (var attempt = 1; attempt <= _kMaxAttempts; attempt++) {
final ProcessResult r;
try {
r = await Process.run(
@@ -181,6 +187,15 @@ class PqlClient {
// pql 1.5+ returns exit 0 with an empty `[]` for zero matches, so any
// non-zero exit is a real error (older pql used exit 2 for empty).
if (r.exitCode != 0) {
// A transient db-busy / still-settling failure — a sidebar pane firing
// its one-shot fetch too early at startup, or contention from
// concurrent pql writes — would otherwise stick until a manual refresh.
// Retry a few times with short backoff first. Genuine errors aren't
// busy, so they still surface immediately. (T-350)
if (attempt < _kMaxAttempts && _isTransient(r.exitCode, stderr)) {
await Future<void>.delayed(Duration(milliseconds: 100 * attempt));
continue;
}
throw PqlException(
'pql ${args.first} failed',
exitCode: r.exitCode,
@@ -191,4 +206,15 @@ class PqlClient {
if (stdout.isEmpty) return null;
return jsonDecode(stdout);
}
// Unreachable: the loop returns, continues, or throws on the final attempt.
throw StateError('pql retry loop exhausted without a result');
}
/// Whether a non-zero pql exit looks like a transient db-busy / not-yet-ready
/// condition worth retrying, vs. a genuine error to surface immediately.
static bool _isTransient(int exitCode, String stderr) {
if (exitCode == _kBusyExitCode) return true;
final s = stderr.toLowerCase();
return s.contains('database is locked') || s.contains('db busy') || s.contains('database busy') || s.contains('locked');
}
}
+6
View File
@@ -113,7 +113,13 @@ static void clide_app_activate(GApplication* application) {
// D-057: frameless custom chrome.
gtk_window_set_decorated(window, FALSE);
gtk_window_set_title(window, "clide");
// Run the decoration suppression on both realize (the X11 hint) and map. The
// Wayland server-decoration request needs a live wl_surface, which GTK only
// creates on map — at realize gdk_wayland_window_get_wl_surface() is still
// null and the request bails, leaving KWin (which defaults to server-side
// decorations on Wayland) to draw its own title bar (T-351).
g_signal_connect(window, "realize", G_CALLBACK(on_window_realize), nullptr);
g_signal_connect(window, "map", G_CALLBACK(on_window_realize), nullptr);
gtk_window_set_default_size(window, 1280, 720);
+1 -1
View File
@@ -13,7 +13,7 @@ description: >-
subsystem handlers (pane, files, editor, git, pql), and the
extension framework.
publish_to: none
version: 2.3.0
version: 2.3.2
repository: https://github.com/postmeridiem/clide
# Short user-facing tagline (the welcome subtitle, web meta
# description, etc.). Baked into lib/src/build_info.g.dart by
@@ -546,6 +546,29 @@ void main() {
});
});
group('DecisionsView — workspace open triggers reload (T-352)', () {
testWidgets('ProjectOpened triggers _refresh', (tester) async {
// The first load can fire before the daemon's pql workDir is the repo; a
// ProjectOpened (fired after the IPC server swaps) must re-fetch.
int listCallCount = 0;
f.ipc.stub('pql.decisions.sync', (_) async => _ok(const {}));
f.ipc.stub('pql.decisions.list', (_) async {
listCallCount++;
return _ok({
'decisions': [_decision(id: 'D-$listCallCount', title: 'open $listCallCount')],
});
});
await pumpView(tester);
expect(listCallCount, 1);
f.services.events.emit(const ProjectOpened(path: '/repo'));
await pumpAsync(tester);
expect(listCallCount, greaterThanOrEqualTo(2));
});
});
group('DecisionsView — concurrent refresh guard', () {
testWidgets('second refresh while one is running sets _pendingRefresh', (tester) async {
final Completer<IpcResponse> firstListCompleter = Completer();
@@ -137,6 +137,21 @@ void main() {
expect(calls, greaterThan(before));
});
testWidgets('refetches when the workspace opens (T-352)', (tester) async {
// The first load can fire before the daemon's pql workDir is the repo; a
// ProjectOpened (fired after the IPC server swaps) must re-fetch.
var calls = 0;
f.ipc.stub('pql.tickets.list', (_) async {
calls++;
return _list([_t('T-1', 'Thing', 'backlog')]);
});
await pumpView(tester);
final before = calls;
f.services.events.emit(const ProjectOpened(path: '/repo'));
await pumpAsync(tester);
expect(calls, greaterThan(before));
});
// -- Type-filter chips (T-343) ------------------------------------------
// The chips own one GestureDetector for both onTap (toggle) and onDoubleTap
// (solo), so a single tap's onTap only fires after the ~300ms double-tap
+39
View File
@@ -150,4 +150,43 @@ void main() {
}
});
});
group('PqlClient — transient retry (T-350)', () {
late Directory tmp;
setUp(() async => tmp = await Directory.systemTemp.createTemp('clide_fakepql_'));
tearDown(() async {
if (await tmp.exists()) await tmp.delete(recursive: true);
});
// A fake `pql` whose body is [body]; a fresh `$0.n` counter file per test
// lets a script "recover" after N invocations.
Future<PqlClient> fakePql(String body) async {
final f = File('${tmp.path}/pql');
await f.writeAsString('#!/bin/sh\n$body\n');
await Process.run('chmod', ['+x', f.path]);
return PqlClient(workDir: Directory.current, toolchain: ToolchainView.resolved(ResolvedPaths(pql: f.path)));
}
test('a genuine (non-busy) error surfaces immediately', () async {
final p = await fakePql('exit 2');
await expectLater(p.files(), throwsA(isA<PqlException>().having((e) => e.exitCode, 'exitCode', 2)));
});
test('a persistent db-busy (exit 69) throws after exhausting retries', () async {
final p = await fakePql('exit 69');
await expectLater(p.files(), throwsA(isA<PqlException>().having((e) => e.exitCode, 'exitCode', 69)));
});
test('a transient db-busy (exit 69) recovers on retry', () async {
final p = await fakePql(r'c="$0.n"; n=$(cat "$c" 2>/dev/null || echo 0); n=$((n+1)); echo "$n" > "$c"; '
r'if [ "$n" -lt 3 ]; then exit 69; fi; echo "[]"');
expect(await p.files(), isEmpty); // retried through two busies to success
});
test('a "database is locked" stderr (non-69 exit) is also retried', () async {
final p = await fakePql(r'c="$0.n"; n=$(cat "$c" 2>/dev/null || echo 0); n=$((n+1)); echo "$n" > "$c"; '
r'if [ "$n" -lt 3 ]; then echo "database is locked" >&2; exit 1; fi; echo "[]"');
expect(await p.files(), isEmpty);
});
});
}