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>
This commit is contained in:
2026-06-10 20:38:44 +02:00
co-authored by Claude Opus 4.8
parent 7cea13c5c0
commit a0501b4a0a
8 changed files with 71 additions and 0 deletions
@@ -3512,3 +3512,4 @@ 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 ('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;
+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);
+8
View File
@@ -3229,3 +3229,11 @@ INSERT INTO tickets (record_id, type, parent_record_id, title, description, stat
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);
+5
View File
@@ -27,6 +27,11 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
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
@@ -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();
}
@@ -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