Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 109 additions & 0 deletions docs/architecture-audit-2026-07-23/AsyncResourceLifecycle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
# Architecture Audit — Async Resource Lifecycle

**Scope:** shared async-resource state, visibility-aware polling, and the migrated TypeScript query owners.
**Date:** 2026-07-23
**Auditor:** ORGII implementation session

## Acceptance criteria

- One owner for `data / error / loading / refreshing / reload` state.
- Equal in-flight scope loads coalesce; explicit refresh starts a new generation.
- A completion from an old repo, workspace, project, filter, language, or webview cannot commit.
- Disabled and changed scopes cannot display data from the previous scope.
- Background polling pauses while hidden, never overlaps, and stops cleanly.
- Mutation/action loading remains separate from query loading.
- Caches are bounded and include the complete resource identity.
- TypeScript, focused lint, lifecycle tests, and `git diff --check` pass.

## Layer 1 — Compilation correctness

- `pnpm run typecheck`: passed.
- Focused ESLint across all changed frontend files: passed.
- Focused Vitest run: 10 files and 118 tests passed.
- `git diff --check`: passed.

## Layer 2 — Dead code and structural deduplication

- Removed the unused `useAsyncData` abstraction.
- `useAsyncResource` is now the single generic owner for request state and generation fencing.
- `useVisibilityPolledData` composes the same owner instead of maintaining a second polling-specific state machine.
- Duplicate initial-load/manual-refresh implementations were removed from the migrated hooks.

## Layer 3 — Naming consistency

| Term | Meaning | Verdict |
| --- | --- | --- |
| `scopeKey` | Complete identity of the visible resource | Explicit and consistent |
| `reload` | Load or background-revalidate, joining an equal in-flight generation | Explicit |
| `refresh` | User-requested superseding generation | Explicit |
| `loading` | Initial load or foreground refresh | Kept for consumer compatibility |
| `refreshing` | Existing data is retained during foreground refresh | Separate state, not overloaded with action progress |
| `operationLoading` / `gatewayLoading` | Explicit user mutation in progress | Correctly remains outside the query resource |

## Layer 4 — Semantic overloading

- Query state and mutation state remain separate in Stash, Gateway, Work Item, and provider flows.
- `background` controls presentation only; it does not weaken generation checks.
- `publish` means an intermediate current-scope cache value, not completion.
- `setData` is limited to optimistic/current-resource updates and cannot write while the resource is disabled.

## Layer 5 — Default branch analysis

| Condition | Result |
| --- | --- |
| `enabled === false` or `scopeKey === null` | Reset to initial data/status and supersede active work |
| Same automatic scope already in flight | Join the existing promise |
| Manual refresh | Supersede and start a new generation |
| Scope changes | Hide old data immediately and reject late completion |
| Fetch rejects | Preserve current-scope data, expose normalized error |
| Hidden document | Retain no polling timer |
| Visibility returns | Run one immediate catch-up pass |
| Poll stops during DOM dirty-check | Effect-local active fence prevents a post-teardown reload |

## Layer 6 — Cross-domain concept leakage

- `useAsyncResource`, `LatestScopedTask`, and `startVisibilityAwarePoll` contain no project, Git, LSP, provider, or webview domain imports.
- Domain fetchers own payload parsing and cache policy; the generic lifecycle layer owns only scheduling and commit eligibility.
- Tauri and HTTP command names remain at their domain call sites.

## Layer 7 — New developer confusion test

- The hook contract documents the difference between automatic load, manual refresh, background reload, and intermediate cache publish.
- A resource's visible state is derived from the current `scopeKey`; consumers do not need local cancellation refs.
- Action states are visibly named and remain local where they represent distinct user operations.

## Layer 8 — Wire protocol and serialization

- No backend command schema or wire payload was changed.
- Serialized scope keys are frontend-only coordinator identities and are parsed by the matching local fetcher.
- Scope keys include all relevant identity fields, including repo ID/path, connection/team/surface, filter, language, and webview label/depth.

## Layer 9 — Init parity

| Entry path | Resource owner | Generation guard | Error normalization |
| --- | --- | ---: | ---: |
| Automatic first load | `useAsyncResource` | yes | yes |
| Manual refresh | `useAsyncResource.refresh` | yes, superseding | yes |
| Background poll | `reload({ background: true })` | yes | yes |
| Cache then live result | `context.publish` + final return | yes | yes |
| Disabled/unmounted scope | effect cleanup | yes | n/a |

## Layer 10 — Resolver symmetry

- Every migrated resource uses the same scope value for loading, visibility, stale-result rejection, and optimistic updates.
- Cached and live values use the same resource identity.
- The commit-diff cache now includes repository identity as well as commit SHA, eliminating cross-repository collisions.

## Systematic sweep

- Searched query hooks for repeated `loading/error/data` owners and manual cancellation/request-ID patterns.
- Searched active runtime code for `setInterval`; migrated non-critical IPC polling peers.
- Kept animation clocks, debounces, durable persistence heartbeats, editor/document FSMs, user-triggered searches, and mutation progress states with their specialized owners.
- Remaining literal `setInterval` hits in the audited directories are UI clocks/simulations or domain-specific lifecycles, not duplicate query polling.

## Completion verdict

- One teardown issue found during audit was fixed: DOM dirty polling now cannot schedule a tree reload after its effect has stopped.
- Relevant architecture layers 1–10 were checked; backend init, schema migration, and resolver-chain changes were not applicable because no backend or wire contract changed.

**Architecture verdict: pass for the audited async-resource and polling scope.**
42 changes: 42 additions & 0 deletions docs/frontend-ui-audit-2026-07-23/AsyncResourceConsumers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Frontend UI Audit — Async Resource Consumers

**Files:** `src/engines/ChatPanel/panels/ProjectPanelView.tsx`, `src/modules/ProjectManager/LinearProjects/useLinearIndexData.tsx`
**Date:** 2026-07-23
**Auditor:** ORGII implementation session

The diff in both files changes data ownership only. It does not add or modify rendered JSX, class names, interactive elements, or layout.

## D1 — Raw HTML vs Design System

| Line | Element | Verdict | Reason | Suggested change |
| --- | --- | --- | --- | --- |
| Changed hunks | none | keep | The refactor introduces no raw interactive or structural HTML. | — |

## D2 — Arbitrary Tailwind Value vs Token

| Line | Value | Verdict | Reason | Suggested change |
| --- | --- | --- | --- | --- |
| Changed hunks | none | keep | No Tailwind or CSS-variable class changed. | — |

## D3 — Hardcoded Sizes / Colors

| Line | Value | Verdict | Reason | Suggested change |
| --- | --- | --- | --- | --- |
| Changed hunks | none | keep | No size or color literal changed. | — |

## D4 — Accessibility

| Line | Element | Verdict | Reason | Suggested change |
| --- | --- | --- | --- | --- |
| Changed hunks | none | keep | No rendered element or interaction contract changed. | — |

## D5 — Visual Patterns Observed

- No new visual pattern was introduced.
- The shared abstraction is a data-lifecycle hook and is not a design-system component candidate.

## Summary

- 0 fixes recommended
- 0 kept exceptions requiring future review
- 0 abstract UI candidates
35 changes: 35 additions & 0 deletions docs/org2-performance-guard-2026-07-23/AsyncResourceLifecycle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Performance Guard — Async Resource Lifecycle

**Scope:** migrated resource fetches, background polling, request caches, and multi-scope lifecycle.
**Date:** 2026-07-23

## Lifecycle matrix

| State | Required behavior | Audited behavior |
| --- | --- | --- |
| Visible and active | Fetch on demand at configured cadence | Recursive polling; next delay starts after settlement |
| Visible and idle | Avoid full reload unless dirty or scheduled safety refresh | DOM uses dirty-check; other retained polls are bounded safety refreshes |
| Hidden | No non-critical polling timer | Timer cleared; visibility return triggers one catch-up pass |
| Scope switch | Previous data and completion cannot appear | Complete scope key plus generation fence |
| Unmount/disable | Stop timer/listener and reject late commits | Poll controller cleanup plus coordinator supersede |
| Offline/error | Set current resource error without a retry storm | No automatic tight retry; configured cadence resumes |
| Repeated mount | No app-lifetime accumulation | Per-hook coordinator owns one active promise; listeners/timers dispose |

## Findings and evidence

| Area | Verdict | Evidence | Change or reason kept | Verification |
| --- | --- | --- | --- | --- |
| Background work | fix | DOM, Inspector, Console, Network, LSP, Git auto-fetch, and Gateway used or consumed polling | Replaced non-critical intervals with visibility-aware non-overlapping recursive polling; DOM teardown received an additional active fence | Visibility controller tests plus focused lifecycle review |
| Memory | fix/keep | Resource retains one state and one active promise; Console cache is 10 sessions × 500 rows, Network is 10 × 200, commit cache is 50 | Preserved existing caps; removed duplicate per-resource state; no new unbounded collection | Unit tests, code inspection |
| Scope/isolation | fix | Previous implementations used local mounted/cancelled flags or incomplete commit cache identity | Complete scope keys and generations now gate every commit; commit cache includes repo identity | Stale-filter, stale-scope, superseding-refresh tests |
| Rendering/hot path | fix | Foreground state was repeatedly toggled by background refreshes | Background reload retains data and avoids spinner flashes; derived grouping remains memoized | Async-resource and polling hook tests |

## Verification

- `pnpm run typecheck`: passed.
- Focused ESLint: passed.
- Focused Vitest: 10 files, 118 tests passed.
- `git diff --check`: passed.
- Real Tauri visible/hidden IPC and CPU measurement was not run in this audit environment.

**Performance verdict: blocked only on real Tauri runtime measurement; static lifecycle gates, compilation, lint, and focused regression tests pass.**
86 changes: 84 additions & 2 deletions src-tauri/crates/session-persistence/src/turn_intents.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
use chrono::Utc;
use rusqlite::{params, Connection, OptionalExtension, Result as SqliteResult};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;

use super::connection::get_connection;

Expand Down Expand Up @@ -266,6 +267,26 @@ pub fn upsert_initial(
status: TurnIntentStatus,
) -> Result<TurnIntentRow, IntentError> {
let conn = get_connection()?;
upsert_initial_on(
&conn,
session_id,
turn_intent_id,
client_message_id,
source,
status,
)
}

/// Connection-scoped variant for callers that atomically update adjacent
/// session lifecycle state in the same SQLite transaction.
pub fn upsert_initial_on(
conn: &Connection,
session_id: &str,
turn_intent_id: &str,
client_message_id: Option<&str>,
source: TurnIntentSource,
status: TurnIntentStatus,
) -> Result<TurnIntentRow, IntentError> {
let now = Utc::now().to_rfc3339();
let inserted = conn.execute(
"INSERT OR IGNORE INTO session_turn_intents
Expand All @@ -292,7 +313,7 @@ pub fn upsert_initial(
updated_at: now,
});
}
get_intent(&conn, session_id, turn_intent_id)?
get_intent(conn, session_id, turn_intent_id)?
.ok_or_else(|| IntentError::NotFound(turn_intent_id.to_string(), session_id.to_string()))
}

Expand All @@ -304,7 +325,17 @@ pub fn update_status(
new_status: TurnIntentStatus,
) -> Result<TurnIntentRow, IntentError> {
let conn = get_connection()?;
let existing = get_intent(&conn, session_id, turn_intent_id)?
update_status_on(&conn, session_id, turn_intent_id, new_status)
}

/// Connection-scoped variant for atomic session + turn-intent transitions.
pub fn update_status_on(
conn: &Connection,
session_id: &str,
turn_intent_id: &str,
new_status: TurnIntentStatus,
) -> Result<TurnIntentRow, IntentError> {
let existing = get_intent(conn, session_id, turn_intent_id)?
.ok_or_else(|| IntentError::NotFound(turn_intent_id.to_string(), session_id.to_string()))?;
if !transition_allowed(existing.status, new_status) {
return Err(IntentError::IllegalTransition {
Expand Down Expand Up @@ -400,6 +431,28 @@ pub fn list_for_session(session_id: &str) -> SqliteResult<Vec<TurnIntentRow>> {
Ok(rows)
}

/// Latest lifecycle row for each requested session, read through one
/// connection so reconnect/focus reconciliation does not fan out into one
/// SQLite task per session.
pub fn latest_for_sessions(session_ids: &[String]) -> SqliteResult<HashMap<String, TurnIntentRow>> {
let conn = get_connection()?;
let mut stmt = conn.prepare_cached(
"SELECT session_id, turn_intent_id, client_message_id, source, status,
created_at, updated_at
FROM session_turn_intents
WHERE session_id = ?1
ORDER BY updated_at DESC, created_at DESC
LIMIT 1",
)?;
let mut latest = HashMap::with_capacity(session_ids.len());
for session_id in session_ids {
if let Some(row) = stmt.query_row([session_id], row_from_sql).optional()? {
latest.insert(session_id.clone(), row);
}
}
Ok(latest)
}

// ============================================
// Tests
// ============================================
Expand Down Expand Up @@ -601,6 +654,35 @@ mod tests {
});
}

#[test]
fn latest_for_sessions_returns_one_current_intent_per_requested_session() {
with_temp_orgii_home(|| {
let first_session = "test-session-batch-a";
let second_session = "test-session-batch-b";
let _ = fresh_intent(first_session, "intent-a-old");
let _ =
update_status(first_session, "intent-a-old", TurnIntentStatus::Running).unwrap();
let _ =
update_status(first_session, "intent-a-old", TurnIntentStatus::Completed).unwrap();
let _ = fresh_intent(first_session, "intent-a-current");
let _ = update_status(first_session, "intent-a-current", TurnIntentStatus::Running)
.unwrap();
let _ = fresh_intent(second_session, "intent-b-current");

let rows = latest_for_sessions(&[
first_session.to_string(),
second_session.to_string(),
"missing-session".to_string(),
])
.unwrap();

assert_eq!(rows.len(), 2);
assert_eq!(rows[first_session].turn_intent_id, "intent-a-current");
assert_eq!(rows[first_session].status, TurnIntentStatus::Running);
assert_eq!(rows[second_session].turn_intent_id, "intent-b-current");
});
}

#[test]
fn restart_reconciliation_closes_every_in_flight_intent() {
with_temp_orgii_home(|| {
Expand Down
3 changes: 3 additions & 0 deletions src-tauri/src/agent_sessions/cli/agent_core_bridge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -190,8 +190,11 @@ fn respond_plan_approval(
None,
Some(AgentExecMode::Build.as_str().to_string()),
None,
None,
None,
)
.await
.map(|_| ())
})
}

Expand Down
Loading